feat: port stroy-bot-objects as objects page
Project object tree with add/edit/delete, drag & drop re-parenting, context menu and Excel export, 1:1 with the legacy web part. The tree widgets live in pages/_tree so the works page can reuse them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@
|
|||||||
- [ ] Пусто — «Объекты не найдены» + «+ Добавить объект» (если `canEdit`)
|
- [ ] Пусто — «Объекты не найдены» + «+ Добавить объект» (если `canEdit`)
|
||||||
- [ ] Тулбар «{N} объектов» (рекурсивно), «Развернуть», «Свернуть»
|
- [ ] Тулбар «{N} объектов» (рекурсивно), «Развернуть», «Свернуть»
|
||||||
- [ ] Дерево: отступ, иконка папки/файла, счётчик детей, шеврон, клик по строке — выделить + раскрыть
|
- [ ] Дерево: отступ, иконка папки/файла, счётчик детей, шеврон, клик по строке — выделить + раскрыть
|
||||||
- [ ] Раскрытые узлы сохраняются между перезагрузками в рамках сессии
|
- [ ] Раскрытые и выделенный узлы сохраняются при перезагрузке дерева после операций (в памяти страницы, как в старом коде; при обновлении страницы браузера — сбрасываются)
|
||||||
- [ ] Уровни: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ (не глубже 3 уровней)
|
- [ ] Уровни: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ (не глубже 3 уровней)
|
||||||
- [ ] Контекстное меню (⋮ и правый клик, только `canEdit`): Добавить объект (не на уровне 2), Редактировать, Работы, Удалить
|
- [ ] Контекстное меню (⋮ и правый клик, только `canEdit`): Добавить объект (не на уровне 2), Редактировать, Работы, Удалить
|
||||||
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
||||||
@@ -95,7 +95,12 @@
|
|||||||
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
||||||
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
||||||
|
|
||||||
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей.
|
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть).
|
||||||
|
|
||||||
|
**Сознательные отличия от objects:**
|
||||||
|
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
||||||
|
- `confirm()` удаления → `confirmModal` с кнопкой «Удалить»; текст тот же, но переносы строк ui-kit показывает одним абзацем.
|
||||||
|
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), экспорт — через клиент корпоративного API и настройку «Базовый URL API» (по умолчанию тот же `https://api.tnsu.kz`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
|||||||
import { BinderPage } from '../pages/binder/BinderPage';
|
import { BinderPage } from '../pages/binder/BinderPage';
|
||||||
import { ReportsPage } from '../pages/reports/ReportsPage';
|
import { ReportsPage } from '../pages/reports/ReportsPage';
|
||||||
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
||||||
|
import { ObjectsPage } from '../pages/objects/ObjectsPage';
|
||||||
|
|
||||||
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
||||||
export const PageOutlet: React.FC = () => {
|
export const PageOutlet: React.FC = () => {
|
||||||
@@ -22,6 +23,8 @@ export const PageOutlet: React.FC = () => {
|
|||||||
return <ReportsPage />;
|
return <ReportsPage />;
|
||||||
case 'import':
|
case 'import':
|
||||||
return <ImportWorksPage />;
|
return <ImportWorksPage />;
|
||||||
|
case 'objects':
|
||||||
|
return <ObjectsPage />;
|
||||||
default:
|
default:
|
||||||
return <SectionPlaceholder />;
|
return <SectionPlaceholder />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ширина меню как в stroy-bot-objects — подписи пунктов не переносятся.
|
||||||
|
$menu-min-width: 210px;
|
||||||
|
// Над модалками SharePoint, но под модалками ui-kit (меню закрывается до их открытия).
|
||||||
|
$menu-z-index: 9999;
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: $menu-z-index;
|
||||||
|
min-width: $menu-min-width;
|
||||||
|
padding: 6px;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-modal;
|
||||||
|
font-family: $font-sans;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px $sp-3;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gold-50;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDanger {
|
||||||
|
color: $red-500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 5px $sp-2;
|
||||||
|
background: $gray-200;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import { Icon, IconName } from '@tnsu/ui-kit';
|
||||||
|
import { useOutsideClose } from '../../shared/hooks/useOutsideClose';
|
||||||
|
import styles from './ContextMenu.module.scss';
|
||||||
|
|
||||||
|
export interface IContextMenuItem<K extends string = string> {
|
||||||
|
key: K;
|
||||||
|
label: string;
|
||||||
|
icon: IconName;
|
||||||
|
danger?: boolean;
|
||||||
|
/** Разделитель перед пунктом (как перед «Удалить» в старых web parts). */
|
||||||
|
dividerBefore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Точка привязки меню в координатах окна (clientX/clientY). */
|
||||||
|
export interface IMenuAnchor {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Меню прижимается правым краем к x (под кнопкой ⋮ в таблице работ). */
|
||||||
|
alignRight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<K extends string> {
|
||||||
|
anchor: IMenuAnchor;
|
||||||
|
items: Array<IContextMenuItem<K>>;
|
||||||
|
onSelect: (key: K) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отступ от края окна, при котором меню разворачивается в другую сторону — как в stroy-bot-objects. */
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
|
||||||
|
interface IPosition {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitIntoViewport(anchor: IMenuAnchor, width: number, height: number): IPosition {
|
||||||
|
let left = anchor.alignRight ? anchor.x - width : anchor.x;
|
||||||
|
let top = anchor.y;
|
||||||
|
if (left + width > window.innerWidth - VIEWPORT_MARGIN) left = anchor.x - width;
|
||||||
|
if (top + height > window.innerHeight - VIEWPORT_MARGIN) top = anchor.y - height;
|
||||||
|
return { left, top };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Всплывающее меню действий над узлом. Рендерится в body (portal), чтобы не обрезаться
|
||||||
|
* контейнером с прокруткой. Закрывается кликом вне меню и по Esc.
|
||||||
|
*/
|
||||||
|
export function ContextMenu<K extends string>({ anchor, items, onSelect, onClose }: Props<K>): React.ReactElement {
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [position, setPosition] = React.useState<IPosition | undefined>(undefined);
|
||||||
|
|
||||||
|
useOutsideClose(menuRef, true, onClose);
|
||||||
|
|
||||||
|
// Размер меню известен только после отрисовки — до этого оно невидимо.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!menuRef.current) return;
|
||||||
|
const rect = menuRef.current.getBoundingClientRect();
|
||||||
|
setPosition(fitIntoViewport(anchor, rect.width, rect.height));
|
||||||
|
}, [anchor]);
|
||||||
|
|
||||||
|
const style: React.CSSProperties = position
|
||||||
|
? { left: position.left, top: position.top }
|
||||||
|
: { left: anchor.x, top: anchor.y, visibility: 'hidden' };
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div className={styles.menu} style={style} ref={menuRef} role="menu">
|
||||||
|
{items.map((item) => (
|
||||||
|
<React.Fragment key={item.key}>
|
||||||
|
{item.dividerBefore && <div className={styles.divider} />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className={item.danger ? `${styles.item} ${styles.itemDanger}` : styles.item}
|
||||||
|
onClick={() => onSelect(item.key)}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} size="sm" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Размеры повторяют TextInput из ui-kit, чтобы поля в одной модалке выглядели одинаково.
|
||||||
|
$input-padding: 11px 13px;
|
||||||
|
$input-font-size: 13.5px;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: $input-padding;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $input-font-size;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid,
|
||||||
|
.invalid:focus {
|
||||||
|
border-color: $red-500;
|
||||||
|
box-shadow: 0 0 0 3px rgba($red-500, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './FormInput.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type?: 'text' | 'number';
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Шаг для type="number" (проектный объём — 0.001). */
|
||||||
|
step?: string;
|
||||||
|
/** Красная рамка незаполненного обязательного поля. */
|
||||||
|
isInvalid?: boolean;
|
||||||
|
/** Выделить текст при фокусе — как при открытии модалки в stroy-bot-objects. */
|
||||||
|
selectOnFocus?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onEnter?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле ввода модалок «Объекты» / «Работы». TextInput из ui-kit не умеет step, ref (возврат фокуса
|
||||||
|
* при ошибке) и состояние ошибки, а старые web parts это использовали. Внешний вид — как у TextInput.
|
||||||
|
*/
|
||||||
|
export const FormInput = React.forwardRef<HTMLInputElement, Props>(
|
||||||
|
({ id, type = 'text', value, onChange, placeholder, step, isInvalid, selectOnFocus, autoFocus, onEnter }, ref) => {
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (event.key === 'Enter' && onEnter) {
|
||||||
|
event.preventDefault();
|
||||||
|
onEnter();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={isInvalid ? `${styles.input} ${styles.invalid}` : styles.input}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
step={step}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
onFocus={selectOnFocus ? (event) => event.target.select() : undefined}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FormInput.displayName = 'FormInput';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Размер золотого квадрата с иконкой — как в шапке stroy-bot-objects.
|
||||||
|
$icon-box-size: 46px;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
@include page-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: $icon-box-size;
|
||||||
|
height: $icon-box-size;
|
||||||
|
border-radius: $r-md;
|
||||||
|
background: rgba($gold-500, 0.14);
|
||||||
|
border: 1px solid rgba($gold-500, 0.3);
|
||||||
|
color: $gold-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $fs-overline;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.8px;
|
||||||
|
color: rgba($white, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-page-title;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: $white;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon, IconName } from '@tnsu/ui-kit';
|
||||||
|
import styles from './TreePageHeader.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: IconName;
|
||||||
|
/** Надпись мелким шрифтом над заголовком (необязательна). */
|
||||||
|
label?: string;
|
||||||
|
title: string;
|
||||||
|
/** Кнопки справа. */
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тёмная шапка страниц «Объекты» и «Работы» — как в старых web parts. */
|
||||||
|
export const TreePageHeader: React.FC<Props> = ({ icon, label, title, actions }) => (
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.icon} aria-hidden="true">
|
||||||
|
<Icon name={icon} />
|
||||||
|
</span>
|
||||||
|
<div className={styles.text}>
|
||||||
|
{label && <div className={styles.label}>{label}</div>}
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
</div>
|
||||||
|
{actions && <div className={styles.actions}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
buildTree,
|
||||||
|
collectDescendantIds,
|
||||||
|
collectParentIds,
|
||||||
|
countNodes,
|
||||||
|
flattenVisible,
|
||||||
|
isDescendant,
|
||||||
|
ITree,
|
||||||
|
toggleId
|
||||||
|
} from './treeRules';
|
||||||
|
|
||||||
|
interface IItem {
|
||||||
|
id: number;
|
||||||
|
parentId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1
|
||||||
|
// ├─ 2
|
||||||
|
// │ └─ 4
|
||||||
|
// └─ 3
|
||||||
|
// 5 (родитель 99 не в выборке — становится корнем)
|
||||||
|
const ITEMS: IItem[] = [
|
||||||
|
{ id: 1 },
|
||||||
|
{ id: 2, parentId: 1 },
|
||||||
|
{ id: 3, parentId: 1 },
|
||||||
|
{ id: 4, parentId: 2 },
|
||||||
|
{ id: 5, parentId: 99 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const build = (items: IItem[] = ITEMS): ITree<IItem, number> =>
|
||||||
|
buildTree(
|
||||||
|
items,
|
||||||
|
(item) => item.id,
|
||||||
|
(item) => item.parentId
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('buildTree', () => {
|
||||||
|
it('корни — узлы без родителя и с родителем не из выборки', () => {
|
||||||
|
expect(build().roots.map((node) => node.id)).toEqual([1, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('уровни — глубина от корня', () => {
|
||||||
|
const { byId } = build();
|
||||||
|
expect([1, 2, 3, 4, 5].map((id) => byId.get(id)?.level)).toEqual([0, 1, 1, 2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('дети в порядке исходной выборки', () => {
|
||||||
|
expect(build().byId.get(1)?.children.map((node) => node.id)).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('порядок в выборке не важен: ребёнок раньше родителя', () => {
|
||||||
|
const tree = build([{ id: 4, parentId: 2 }, { id: 2 }]);
|
||||||
|
expect(tree.roots.map((node) => node.id)).toEqual([2]);
|
||||||
|
expect(tree.byId.get(4)?.level).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDescendant / collectDescendantIds', () => {
|
||||||
|
it('внук — потомок, сосед и сам узел — нет', () => {
|
||||||
|
const { byId } = build();
|
||||||
|
expect(isDescendant(byId, 1, 4)).toBe(true);
|
||||||
|
expect(isDescendant(byId, 2, 3)).toBe(false);
|
||||||
|
expect(isDescendant(byId, 2, 2)).toBe(false);
|
||||||
|
expect(isDescendant(byId, 404, 1)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('все потомки без самого узла', () => {
|
||||||
|
expect(Array.from(collectDescendantIds(build().byId, 1)).sort()).toEqual([2, 3, 4]);
|
||||||
|
expect(collectDescendantIds(build().byId, 4).size).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countNodes / collectParentIds', () => {
|
||||||
|
it('считает все узлы рекурсивно', () => {
|
||||||
|
expect(countNodes(build().roots)).toEqual(5);
|
||||||
|
expect(countNodes([])).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('родители — только узлы с детьми', () => {
|
||||||
|
expect(collectParentIds(build().roots)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flattenVisible', () => {
|
||||||
|
it('всё свёрнуто — только корни', () => {
|
||||||
|
expect(flattenVisible(build().roots, new Set<number>()).map((node) => node.id)).toEqual([1, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раскрытый родитель показывает детей, свёрнутый внук — нет', () => {
|
||||||
|
expect(flattenVisible(build().roots, new Set([1])).map((node) => node.id)).toEqual([1, 2, 3, 5]);
|
||||||
|
expect(flattenVisible(build().roots, new Set([1, 2])).map((node) => node.id)).toEqual([1, 2, 4, 3, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('раскрытие без раскрытого предка ничего не показывает', () => {
|
||||||
|
expect(flattenVisible(build().roots, new Set([2])).map((node) => node.id)).toEqual([1, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleId', () => {
|
||||||
|
it('возвращает новое множество, исходное не меняется', () => {
|
||||||
|
const ids = new Set([1]);
|
||||||
|
expect(Array.from(toggleId(ids, 2))).toEqual([1, 2]);
|
||||||
|
expect(Array.from(toggleId(ids, 1))).toEqual([]);
|
||||||
|
expect(Array.from(ids)).toEqual([1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Общие правила дерева для страниц «Объекты» и «Работы».
|
||||||
|
// Логика 1:1 с _buildTree / _isDescendant / _countAll / _flattenTree старых web parts:
|
||||||
|
// узел без родителя или с родителем не из выборки — корень; уровень — глубина от корня.
|
||||||
|
|
||||||
|
export interface ITreeNode<T, K> {
|
||||||
|
id: K;
|
||||||
|
item: T;
|
||||||
|
children: Array<ITreeNode<T, K>>;
|
||||||
|
/** 0 — корень. */
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITree<T, K> {
|
||||||
|
roots: Array<ITreeNode<T, K>>;
|
||||||
|
byId: Map<K, ITreeNode<T, K>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree<T, K>(
|
||||||
|
items: T[],
|
||||||
|
getId: (item: T) => K,
|
||||||
|
getParentId: (item: T) => K | undefined
|
||||||
|
): ITree<T, K> {
|
||||||
|
const byId = new Map<K, ITreeNode<T, K>>();
|
||||||
|
items.forEach((item) => byId.set(getId(item), { id: getId(item), item, children: [], level: 0 }));
|
||||||
|
|
||||||
|
const roots: Array<ITreeNode<T, K>> = [];
|
||||||
|
items.forEach((item) => {
|
||||||
|
const node = byId.get(getId(item));
|
||||||
|
const parentId = getParentId(item);
|
||||||
|
const parent = parentId === undefined ? undefined : byId.get(parentId);
|
||||||
|
if (!node) return;
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assignLevels(roots, 0);
|
||||||
|
return { roots, byId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignLevels<T, K>(nodes: Array<ITreeNode<T, K>>, level: number): void {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
node.level = level;
|
||||||
|
assignLevels(node.children, level + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Является ли candidateId потомком nodeId (нельзя перенести узел в себя или своих потомков). */
|
||||||
|
export function isDescendant<T, K>(byId: Map<K, ITreeNode<T, K>>, nodeId: K, candidateId: K): boolean {
|
||||||
|
const node = byId.get(nodeId);
|
||||||
|
if (!node) return false;
|
||||||
|
return node.children.some((child) => child.id === candidateId || isDescendant(byId, child.id, candidateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id всех потомков узла (без самого узла). */
|
||||||
|
export function collectDescendantIds<T, K>(byId: Map<K, ITreeNode<T, K>>, nodeId: K): Set<K> {
|
||||||
|
const ids = new Set<K>();
|
||||||
|
const collect = (id: K): void => {
|
||||||
|
const node = byId.get(id);
|
||||||
|
if (!node) return;
|
||||||
|
node.children.forEach((child) => {
|
||||||
|
ids.add(child.id);
|
||||||
|
collect(child.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
collect(nodeId);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Количество узлов дерева (рекурсивно). */
|
||||||
|
export function countNodes<T, K>(nodes: Array<ITreeNode<T, K>>): number {
|
||||||
|
return nodes.reduce((sum, node) => sum + 1 + countNodes(node.children), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id всех узлов, у которых есть дети, — для «Развернуть всё». */
|
||||||
|
export function collectParentIds<T, K>(nodes: Array<ITreeNode<T, K>>): K[] {
|
||||||
|
const ids: K[] = [];
|
||||||
|
const traverse = (list: Array<ITreeNode<T, K>>): void => {
|
||||||
|
list.forEach((node) => {
|
||||||
|
if (node.children.length === 0) return;
|
||||||
|
ids.push(node.id);
|
||||||
|
traverse(node.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(nodes);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Плоский список видимых узлов: дети показываются только у раскрытых родителей. */
|
||||||
|
export function flattenVisible<T, K>(nodes: Array<ITreeNode<T, K>>, expandedIds: Set<K>): Array<ITreeNode<T, K>> {
|
||||||
|
const result: Array<ITreeNode<T, K>> = [];
|
||||||
|
const traverse = (list: Array<ITreeNode<T, K>>): void => {
|
||||||
|
list.forEach((node) => {
|
||||||
|
result.push(node);
|
||||||
|
if (node.children.length > 0 && expandedIds.has(node.id)) {
|
||||||
|
traverse(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
traverse(nodes);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Новое множество с переключённым id — state не мутируется. */
|
||||||
|
export function toggleId<K>(ids: Set<K>, id: K): Set<K> {
|
||||||
|
const next = new Set(ids);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Button, Dialog, FormField, Select, Spinner, TextInput } from '@tnsu/ui-kit';
|
||||||
|
import { FormInput } from '../_tree/FormInput';
|
||||||
|
import {
|
||||||
|
getErrorText,
|
||||||
|
getModalTitle,
|
||||||
|
getParentDisplay,
|
||||||
|
getParentOptions,
|
||||||
|
getSubmitLabel,
|
||||||
|
isParentSelectable
|
||||||
|
} from './objectsRules';
|
||||||
|
import { IObjectModalTarget, ObjectTree } from './objectsTypes';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
import styles from './ObjectsPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = OBJECTS_TEXTS.modal;
|
||||||
|
const TITLE_INPUT_ID = 'stroybot-object-title';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: IObjectModalTarget;
|
||||||
|
tree: ObjectTree;
|
||||||
|
projectTitle: string;
|
||||||
|
onSubmit: (title: string, parentValue: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Модалка добавления / правки объекта. Монтируется заново при каждом открытии. */
|
||||||
|
export const ObjectModal: React.FC<Props> = ({ target, tree, projectTitle, onSubmit, onClose }) => {
|
||||||
|
const { mode, node } = target;
|
||||||
|
const [title, setTitle] = React.useState<string>(mode === 'edit' && node ? node.item.title : '');
|
||||||
|
const [parentValue, setParentValue] = React.useState<string>(node?.item.parentId ? String(node.item.parentId) : '');
|
||||||
|
const [isTitleMissing, setIsTitleMissing] = React.useState<boolean>(false);
|
||||||
|
const [isSaving, setIsSaving] = React.useState<boolean>(false);
|
||||||
|
const [saveError, setSaveError] = React.useState<string | undefined>(undefined);
|
||||||
|
const titleRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onTitleChange = (value: string): void => {
|
||||||
|
setTitle(value);
|
||||||
|
setIsTitleMissing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (): Promise<void> => {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setIsTitleMissing(true);
|
||||||
|
titleRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveError(undefined);
|
||||||
|
try {
|
||||||
|
await onSubmit(trimmed, parentValue);
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(OBJECTS_TEXTS.saveError(getErrorText(error)));
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = (): void => {
|
||||||
|
submit().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||||
|
{TEXTS.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onSave} disabled={isSaving}>
|
||||||
|
{getSubmitLabel(target)}
|
||||||
|
{isSaving && <Spinner size="xSmall" />}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open title={getModalTitle(target)} onDismiss={onClose} footer={footer} width="sm">
|
||||||
|
<div className={styles.modalBody}>
|
||||||
|
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||||
|
<FormField label={TEXTS.titleLabel} htmlFor={TITLE_INPUT_ID} required>
|
||||||
|
<FormInput
|
||||||
|
id={TITLE_INPUT_ID}
|
||||||
|
ref={titleRef}
|
||||||
|
value={title}
|
||||||
|
onChange={onTitleChange}
|
||||||
|
placeholder={TEXTS.titlePlaceholder}
|
||||||
|
isInvalid={isTitleMissing}
|
||||||
|
selectOnFocus
|
||||||
|
autoFocus
|
||||||
|
onEnter={onSave}
|
||||||
|
/>
|
||||||
|
{isTitleMissing && <span className={styles.fieldError}>{TEXTS.titleRequired}</span>}
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{mode !== 'addRoot' && node && (
|
||||||
|
<FormField label={TEXTS.parentLabel}>
|
||||||
|
{isParentSelectable(target) ? (
|
||||||
|
<Select value={parentValue} onChange={setParentValue} options={getParentOptions(node, tree)} />
|
||||||
|
) : (
|
||||||
|
<TextInput value={getParentDisplay(target, tree)} onChange={() => undefined} readOnly />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label={TEXTS.projectLabel}>
|
||||||
|
<TextInput value={projectTitle} onChange={() => undefined} readOnly />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: ObjectNode;
|
||||||
|
canEdit: boolean;
|
||||||
|
view: IObjectTreeView;
|
||||||
|
drag: IObjectDrag;
|
||||||
|
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отступ строки: базовый + шаг на уровень — как в stroy-bot-objects. */
|
||||||
|
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 : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */
|
||||||
|
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onOpenMenu }) => {
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const isExpanded = view.expandedIds.has(node.id);
|
||||||
|
|
||||||
|
const onToggleClick = (event: React.MouseEvent): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (hasChildren) view.toggle(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// stopPropagation: иначе этот же клик дойдёт до document и сразу закроет открытое меню.
|
||||||
|
const onMenuClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
view.select(node.id);
|
||||||
|
onOpenMenu(node, { x: rect.right, y: rect.bottom });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenu = (event: React.MouseEvent): void => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
event.preventDefault();
|
||||||
|
view.select(node.id);
|
||||||
|
onOpenMenu(node, { x: event.clientX, y: event.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DropZone targetId={node.id} position="before" drag={drag} />
|
||||||
|
<div
|
||||||
|
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === 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}
|
||||||
|
>
|
||||||
|
<span className={styles.nodeToggle} onClick={onToggleClick}>
|
||||||
|
{hasChildren ? (
|
||||||
|
<span className={isExpanded ? styles.chevronExpanded : styles.chevron}>
|
||||||
|
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} size="sm" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.leafDot} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={hasChildren ? styles.iconFolder : styles.iconFile} aria-hidden="true">
|
||||||
|
<Icon name={hasChildren ? 'folder' : 'document'} size="sm" />
|
||||||
|
</span>
|
||||||
|
<span className={styles.nodeTitle}>{node.item.title}</span>
|
||||||
|
{hasChildren && <span className={styles.nodeBadge}>{node.children.length}</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.nodeMenuButton}
|
||||||
|
title={OBJECTS_TEXTS.nodeMenuTooltip}
|
||||||
|
onClick={onMenuClick}
|
||||||
|
>
|
||||||
|
<Icon name="more" size="sm" />
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DropZone targetId={node.id} position="after" drag={drag} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, Icon, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { TreePageHeader } from '../_tree/TreePageHeader';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
const TEXTS = OBJECTS_TEXTS.header;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
isExporting: boolean;
|
||||||
|
onAddRoot: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Шапка «Объекты проекта»: название проекта, «Добавить объект» (только с правом edit0) и «Экспорт». */
|
||||||
|
export const ObjectsHeader: React.FC<Props> = ({ title, canEdit, isExporting, onAddRoot, onExport }) => (
|
||||||
|
<TreePageHeader
|
||||||
|
icon="folder"
|
||||||
|
label={TEXTS.label}
|
||||||
|
title={title}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="secondary" size="sm" title={TEXTS.addRootTooltip} onClick={onAddRoot}>
|
||||||
|
<Icon name="plus" size="sm" /> {TEXTS.addRoot}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" size="sm" title={TEXTS.exportTooltip} onClick={onExport} disabled={isExporting}>
|
||||||
|
{isExporting ? (
|
||||||
|
<Spinner size="xSmall" label={TEXTS.exporting} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="download" size="sm" /> {TEXTS.export}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Размеры дерева — как в stroy-bot-objects.
|
||||||
|
$tree-max-height: 600px; // длинное дерево прокручивается внутри карточки
|
||||||
|
$toggle-size: 18px;
|
||||||
|
$leaf-dot-size: 5px;
|
||||||
|
$badge-size: 22px;
|
||||||
|
$menu-button-size: 28px;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
padding: 48px $sp-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBlock {
|
||||||
|
padding: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Панель над деревом ──────────────────────────────────────────────────────
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $sp-3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px $sp-4;
|
||||||
|
background: $gray-50;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-size: $fs-overline;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbarActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Дерево ──────────────────────────────────────────────────────────────────
|
||||||
|
.tree {
|
||||||
|
max-height: $tree-max-height;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $sp-2 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 1px $sp-2;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
padding-right: $sp-4;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
cursor: default;
|
||||||
|
transition: background 0.18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeRowParent {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeRowSelected {
|
||||||
|
background: $gold-50;
|
||||||
|
|
||||||
|
.nodeTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeRowDragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: $toggle-size;
|
||||||
|
height: $toggle-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron,
|
||||||
|
.chevronExpanded {
|
||||||
|
display: inline-flex;
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronExpanded {
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leafDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: $leaf-dot-size;
|
||||||
|
height: $leaf-dot-size;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconFolder,
|
||||||
|
.iconFile {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconFolder {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconFile {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeTitle {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: $navy-900;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: $badge-size;
|
||||||
|
height: $badge-size;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: $r-full;
|
||||||
|
background: $navy-900;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gold-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeMenuButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: $menu-button-size;
|
||||||
|
height: $menu-button-size;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
border-color: $gray-200;
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
|
||||||
|
// Высоты 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;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
|
import { ObjectsPageContent } from './ObjectsPageContent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раздел «Объекты» (бывший stroy-bot-objects): дерево объектов проекта из ?projectId=.
|
||||||
|
* Смена проекта — новое состояние страницы (раскрытые узлы, выделение, меню).
|
||||||
|
*/
|
||||||
|
export const ObjectsPage: React.FC = () => {
|
||||||
|
const { route } = useRouter();
|
||||||
|
return <ObjectsPageContent key={route.projectId ?? ''} projectId={route.projectId} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { useRouter } from '../../app/routing/RouterContext';
|
||||||
|
import { ContextMenu, IMenuAnchor } from '../_tree/ContextMenu';
|
||||||
|
import { createObjectsRepository } from './objectsRepository';
|
||||||
|
import { IObjectModalTarget, ObjectNode } from './objectsTypes';
|
||||||
|
import { buildObjectMenuItems, ObjectMenuAction } from './objectMenu';
|
||||||
|
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';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
import styles from './ObjectsPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IOpenMenu {
|
||||||
|
node: ObjectNode;
|
||||||
|
anchor: IMenuAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Страница «Объекты»: шапка, дерево, меню узла, модалка. Данные и действия — в хуках. */
|
||||||
|
export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||||
|
const services = useAppServices();
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const repository = React.useMemo(
|
||||||
|
() =>
|
||||||
|
createObjectsRepository({
|
||||||
|
sharePoint: services.sharePoint,
|
||||||
|
tnsuApi: services.tnsuApi,
|
||||||
|
siteUrl: services.settings.siteUrl,
|
||||||
|
apiBaseUrl: services.settings.apiBaseUrl
|
||||||
|
}),
|
||||||
|
[services]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = useObjectsData(repository, projectId);
|
||||||
|
const actions = useObjectActions({
|
||||||
|
repository,
|
||||||
|
projectTitle: data.projectTitle,
|
||||||
|
tree: data.tree,
|
||||||
|
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);
|
||||||
|
|
||||||
|
const openAddRoot = (): void => setModalTarget({ mode: 'addRoot', node: undefined });
|
||||||
|
|
||||||
|
const onMenuSelect = (node: ObjectNode, action: ObjectMenuAction): void => {
|
||||||
|
setMenu(undefined);
|
||||||
|
if (action === 'add' || action === 'edit') {
|
||||||
|
setModalTarget({ mode: action, node });
|
||||||
|
} else if (action === 'works') {
|
||||||
|
navigate({ page: 'works', projectId, objectId: String(node.id) });
|
||||||
|
} else {
|
||||||
|
actions.deleteObject(node).catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onModalSubmit = async (title: string, parentValue: string): Promise<void> => {
|
||||||
|
if (!modalTarget) return;
|
||||||
|
await actions.saveObject(modalTarget, title, parentValue);
|
||||||
|
setModalTarget(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerTitle =
|
||||||
|
data.status === 'error' ? OBJECTS_TEXTS.header.titleError : data.projectTitle ?? OBJECTS_TEXTS.header.titleLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<ObjectsHeader
|
||||||
|
title={headerTitle}
|
||||||
|
canEdit={data.canEdit}
|
||||||
|
isExporting={exporter.isExporting}
|
||||||
|
onAddRoot={openAddRoot}
|
||||||
|
onExport={() => {
|
||||||
|
exporter.exportExcel().catch(() => undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{exporter.exportError && <Alert variant="err">{exporter.exportError}</Alert>}
|
||||||
|
{actions.actionError && <Alert variant="err">{actions.actionError}</Alert>}
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
{data.status === 'loading' && <Spinner block label={OBJECTS_TEXTS.loading} className={styles.loader} />}
|
||||||
|
{data.status === 'error' && (
|
||||||
|
<div className={styles.errorBlock}>
|
||||||
|
<Alert variant="err">{data.errorMessage}</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.status === 'ready' && (
|
||||||
|
<ObjectsTree
|
||||||
|
tree={data.tree}
|
||||||
|
canEdit={data.canEdit}
|
||||||
|
view={view}
|
||||||
|
drag={drag}
|
||||||
|
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
|
||||||
|
onAddRoot={openAddRoot}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{menu && (
|
||||||
|
<ContextMenu
|
||||||
|
anchor={menu.anchor}
|
||||||
|
items={buildObjectMenuItems(menu.node)}
|
||||||
|
onSelect={(key) => onMenuSelect(menu.node, key)}
|
||||||
|
onClose={() => setMenu(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalTarget && (
|
||||||
|
<ObjectModal
|
||||||
|
target={modalTarget}
|
||||||
|
tree={data.tree}
|
||||||
|
projectTitle={data.projectTitle ?? ''}
|
||||||
|
onSubmit={onModalSubmit}
|
||||||
|
onClose={() => setModalTarget(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@tnsu/ui-kit';
|
||||||
|
import { EmptyState } from '../../shared/components/EmptyState';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 }) => (
|
||||||
|
<>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
|
||||||
|
<div className={styles.toolbarActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={view.expandAll}>
|
||||||
|
{OBJECTS_TEXTS.toolbar.expandAll}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={view.collapseAll}>
|
||||||
|
{OBJECTS_TEXTS.toolbar.collapseAll}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tree.roots.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon="folder"
|
||||||
|
title={OBJECTS_TEXTS.empty}
|
||||||
|
action={
|
||||||
|
canEdit && (
|
||||||
|
<Button variant="primary" onClick={onAddRoot}>
|
||||||
|
{OBJECTS_TEXTS.emptyAdd}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.tree} onDrop={drag.onDrop}>
|
||||||
|
{tree.roots.map((node) => (
|
||||||
|
<ObjectTreeNode
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
canEdit={canEdit}
|
||||||
|
view={view}
|
||||||
|
drag={drag}
|
||||||
|
onOpenMenu={onOpenMenu}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { IContextMenuItem } from '../_tree/ContextMenu';
|
||||||
|
import { canAddChild } from './objectsRules';
|
||||||
|
import { ObjectNode } from './objectsTypes';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export type ObjectMenuAction = 'add' | 'edit' | 'works' | 'delete';
|
||||||
|
|
||||||
|
const TEXTS = OBJECTS_TEXTS.menu;
|
||||||
|
|
||||||
|
/** Пункты меню узла. «Добавить объект» скрыт у Вида работ (уровень 2) — глубже дерево не растёт. */
|
||||||
|
export function buildObjectMenuItems(node: ObjectNode): Array<IContextMenuItem<ObjectMenuAction>> {
|
||||||
|
const items: Array<IContextMenuItem<ObjectMenuAction>> = [];
|
||||||
|
if (canAddChild(node)) {
|
||||||
|
items.push({ key: 'add', label: TEXTS.add, icon: 'plus' });
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
{ key: 'edit', label: TEXTS.edit, icon: 'edit' },
|
||||||
|
{ key: 'works', label: TEXTS.works, icon: 'clipboard' },
|
||||||
|
{ key: 'delete', label: TEXTS.delete, icon: 'trash', danger: true, dividerBefore: true }
|
||||||
|
);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
accessUrl,
|
||||||
|
createObjectsRepository,
|
||||||
|
IObjectsRepository,
|
||||||
|
objectItemUrl,
|
||||||
|
objectsByProjectUrl,
|
||||||
|
objectsEntityTypeUrl,
|
||||||
|
objectsListItemsUrl,
|
||||||
|
projectObjectsExportUrl,
|
||||||
|
projectTitleUrl
|
||||||
|
} from './objectsRepository';
|
||||||
|
import { createFakeHttpClient, IFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
||||||
|
|
||||||
|
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
|
||||||
|
const API = 'https://api.tnsu.kz';
|
||||||
|
const OBJECTS = encodeURIComponent('Объекты');
|
||||||
|
const PROJECTS = encodeURIComponent('Проекты');
|
||||||
|
const ENTITY_TYPE = 'SP.Data.ObjectsListItem';
|
||||||
|
|
||||||
|
const VERBOSE_HEADERS = {
|
||||||
|
Accept: 'application/json;odata=verbose',
|
||||||
|
'Content-Type': 'application/json;odata=verbose',
|
||||||
|
'odata-version': ''
|
||||||
|
};
|
||||||
|
const MERGE_HEADERS = { ...VERBOSE_HEADERS, 'IF-MATCH': '*', 'X-HTTP-Method': 'MERGE' };
|
||||||
|
|
||||||
|
const ENTITY_TYPE_ROUTE: IFakeRoute = {
|
||||||
|
match: 'ListItemEntityTypeFullName',
|
||||||
|
response: { body: { ListItemEntityTypeFullName: ENTITY_TYPE } }
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
spRoutes: IFakeRoute[],
|
||||||
|
apiRoutes: IFakeRoute[] = []
|
||||||
|
): { sharePoint: IFakeHttpClient; tnsuApi: IFakeHttpClient; repository: IObjectsRepository } {
|
||||||
|
const sharePoint = createFakeHttpClient(spRoutes);
|
||||||
|
const tnsuApi = createFakeHttpClient(apiRoutes);
|
||||||
|
const repository = createObjectsRepository({ sharePoint, tnsuApi, siteUrl: SITE, apiBaseUrl: API });
|
||||||
|
return { sharePoint, tnsuApi, repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyOf(client: IFakeHttpClient, index: number): unknown {
|
||||||
|
return JSON.parse(String(client.requests[index].request.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('адреса запросов (1:1 со stroy-bot-objects)', () => {
|
||||||
|
it('название проекта по id', () => {
|
||||||
|
expect(projectTitleUrl(SITE, '5')).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('${PROJECTS}')/items(5)?$select=Id,Title`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объекты проекта: фильтр по названию закодирован, апостроф удвоен', () => {
|
||||||
|
expect(objectsByProjectUrl(SITE, "ЖК 'Сункар'")).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('${OBJECTS}')/items` +
|
||||||
|
`?$select=Id,Title,parent/Id&$expand=parent` +
|
||||||
|
`&$filter=${encodeURIComponent("project eq 'ЖК ''Сункар'''")}` +
|
||||||
|
`&$top=5000&$orderby=Title`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('«Доступы» по projectId и userId (название списка не кодируется)', () => {
|
||||||
|
expect(accessUrl(SITE, '5', 42)).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('Доступы')/items` +
|
||||||
|
`?$select=Id,edit0&$top=1&$filter=projectId eq 5 and userId eq 42`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('элементы, элемент и тип элементов списка «Объекты»', () => {
|
||||||
|
expect(objectsListItemsUrl(SITE)).toEqual(`${SITE}/_api/web/lists/getbytitle('${OBJECTS}')/items`);
|
||||||
|
expect(objectItemUrl(SITE, 7)).toEqual(`${SITE}/_api/web/lists/getbytitle('${OBJECTS}')/items(7)`);
|
||||||
|
expect(objectsEntityTypeUrl(SITE)).toEqual(
|
||||||
|
`${SITE}/_api/web/lists/getbytitle('${OBJECTS}')?$select=ListItemEntityTypeFullName`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('выгрузка Excel', () => {
|
||||||
|
expect(projectObjectsExportUrl(API, '5')).toEqual(`${API}/Report/ProjectObjects?projectId=5`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('чтение', () => {
|
||||||
|
it('название проекта; не найден — «Проект с ID=… не найден»', async () => {
|
||||||
|
const ok = setup([{ match: 'items(5)', response: { body: { Id: 5, Title: 'Сункар' } } }]);
|
||||||
|
await expect(ok.repository.fetchProjectTitle('5')).resolves.toEqual('Сункар');
|
||||||
|
|
||||||
|
const missing = setup([{ match: 'items(6)', response: { status: 404 } }]);
|
||||||
|
await expect(missing.repository.fetchProjectTitle('6')).rejects.toThrow('Проект с ID=6 не найден');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объекты: parent/Id → parentId, пустой lookup → корень', async () => {
|
||||||
|
const { repository } = setup([
|
||||||
|
{
|
||||||
|
match: '$expand=parent',
|
||||||
|
response: { body: { value: [{ Id: 1, Title: 'А', parent: null }, { Id: 2, Title: 'Б', parent: { Id: 1 } }] } }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await expect(repository.fetchObjects('Сункар')).resolves.toEqual([
|
||||||
|
{ id: 1, title: 'А', parentId: undefined },
|
||||||
|
{ id: 2, title: 'Б', parentId: 1 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка объектов: текст из error.message, иначе «Ошибка загрузки списка "Объекты"»', async () => {
|
||||||
|
const withMessage = setup([
|
||||||
|
{ match: '$expand=parent', response: { status: 400, body: { error: { message: 'Поле не найдено' } } } }
|
||||||
|
]);
|
||||||
|
await expect(withMessage.repository.fetchObjects('X')).rejects.toThrow('Поле не найдено');
|
||||||
|
|
||||||
|
const withoutMessage = setup([{ match: '$expand=parent', response: { status: 500, body: {} } }]);
|
||||||
|
await expect(withoutMessage.repository.fetchObjects('X')).rejects.toThrow('Ошибка загрузки списка "Объекты"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('права (Доступы → edit0)', () => {
|
||||||
|
const access = (body: unknown, status: number = 200): IFakeRoute[] => [
|
||||||
|
{ match: 'currentuser', response: { body: { Id: 42 } } },
|
||||||
|
{ match: "getbytitle('Доступы')", response: { status, body } }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('первая запись с edit0 = true — можно редактировать', async () => {
|
||||||
|
const { repository, sharePoint } = setup(access({ value: [{ Id: 1, edit0: true }] }));
|
||||||
|
await expect(repository.fetchCanEdit('5')).resolves.toBe(true);
|
||||||
|
expect(sharePoint.requests.map((request) => request.url)).toEqual([
|
||||||
|
`${SITE}/_api/web/currentuser?$select=Id`,
|
||||||
|
accessUrl(SITE, '5', 42)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет записи, edit0 = false или ошибка — нельзя', async () => {
|
||||||
|
await expect(setup(access({ value: [] })).repository.fetchCanEdit('5')).resolves.toBe(false);
|
||||||
|
await expect(setup(access({ value: [{ Id: 1, edit0: false }] })).repository.fetchCanEdit('5')).resolves.toBe(false);
|
||||||
|
await expect(setup(access({}, 500)).repository.fetchCanEdit('5')).resolves.toBe(false);
|
||||||
|
await expect(setup([]).repository.fetchCanEdit('5')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('запись', () => {
|
||||||
|
it('создание: тип элемента запрашивается один раз, verbose-заголовки, project = название проекта', async () => {
|
||||||
|
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items', response: { status: 201 } }]);
|
||||||
|
|
||||||
|
await repository.createObject('Сункар', 'Корпус 1', undefined);
|
||||||
|
await repository.createObject('Сункар', 'Секция А', 1);
|
||||||
|
|
||||||
|
expect(sharePoint.requests.map((request) => request.url)).toEqual([
|
||||||
|
objectsEntityTypeUrl(SITE),
|
||||||
|
objectsListItemsUrl(SITE),
|
||||||
|
objectsListItemsUrl(SITE)
|
||||||
|
]);
|
||||||
|
expect(sharePoint.requests[1].request).toMatchObject({ method: 'POST', headers: VERBOSE_HEADERS });
|
||||||
|
expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Корпус 1', project: 'Сункар' });
|
||||||
|
expect(bodyOf(sharePoint, 2)).toEqual({
|
||||||
|
__metadata: { type: ENTITY_TYPE },
|
||||||
|
Title: 'Секция А',
|
||||||
|
project: 'Сункар',
|
||||||
|
parentId: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('правка: MERGE, parentId — только при смене родителя', async () => {
|
||||||
|
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
|
||||||
|
|
||||||
|
await repository.updateObject(7, 'Новое', undefined);
|
||||||
|
await repository.updateObject(7, 'Новое', 3);
|
||||||
|
|
||||||
|
expect(sharePoint.requests[1]).toEqual({
|
||||||
|
url: objectItemUrl(SITE, 7),
|
||||||
|
request: {
|
||||||
|
method: 'POST',
|
||||||
|
headers: MERGE_HEADERS,
|
||||||
|
body: JSON.stringify({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое' })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
{ match: '/items', response: { status: 400, body: { error: { message: { value: 'Нет доступа' } } } } }
|
||||||
|
]);
|
||||||
|
await expect(verbose.repository.createObject('П', 'Т', undefined)).rejects.toThrow('Нет доступа');
|
||||||
|
|
||||||
|
const plain = setup([
|
||||||
|
ENTITY_TYPE_ROUTE,
|
||||||
|
{ match: '/items', response: { status: 400, body: { error: { message: 'Плохо' } } } }
|
||||||
|
]);
|
||||||
|
await expect(plain.repository.createObject('П', 'Т', undefined)).rejects.toThrow('Плохо');
|
||||||
|
|
||||||
|
const empty = setup([ENTITY_TYPE_ROUTE, { match: '/items', response: { status: 500, body: {} } }]);
|
||||||
|
await expect(empty.repository.createObject('П', 'Т', undefined)).rejects.toThrow('Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('удаление: DELETE с IF-MATCH и X-HTTP-Method; ошибка — statusText', async () => {
|
||||||
|
const ok = setup([{ match: '/items(9)', response: { status: 200 } }]);
|
||||||
|
await ok.repository.deleteObject(9);
|
||||||
|
expect(ok.sharePoint.requests[0]).toEqual({
|
||||||
|
url: objectItemUrl(SITE, 9),
|
||||||
|
request: { method: 'DELETE', headers: { 'IF-MATCH': '*', 'X-HTTP-Method': 'DELETE' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const failed = setup([{ match: '/items(9)', response: { status: 403 } }]);
|
||||||
|
await expect(failed.repository.deleteObject(9)).rejects.toThrow('Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('выгрузка Excel', () => {
|
||||||
|
it('файл из корпоративного API; ошибка — «Ошибка сервера: {status}»', async () => {
|
||||||
|
const ok = setup([], [{ match: 'ProjectObjects', response: { body: 'xlsx' } }]);
|
||||||
|
await expect(ok.repository.downloadObjectsExcel('5')).resolves.toBeDefined();
|
||||||
|
expect(ok.tnsuApi.requests[0].url).toEqual(`${API}/Report/ProjectObjects?projectId=5`);
|
||||||
|
|
||||||
|
const failed = setup([], [{ match: 'ProjectObjects', response: { status: 502 } }]);
|
||||||
|
await expect(failed.repository.downloadObjectsExcel('5')).rejects.toThrow('Ошибка сервера: 502');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { IHttpClient, IHttpResponse } from '../../services/http/IHttpClient';
|
||||||
|
import { fetchCurrentUserId, ISpCollection, listItemsUrl } from '../../shared/sharepoint/spRest';
|
||||||
|
import { LISTS } from '../../config/lists';
|
||||||
|
import { IObjectRow, IProjectObject, ISpErrorBody } from './objectsTypes';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
// Запросы страницы «Объекты» — 1:1 со stroy-bot-objects (ObjectsByProjectWebPart.ts).
|
||||||
|
// Название списка в адресе кодируется encodeURIComponent, как в старом коде; у «Доступы» — нет.
|
||||||
|
|
||||||
|
/** Предел выборки «Объекты» — как в старом коде. */
|
||||||
|
const OBJECTS_LIMIT = 5000;
|
||||||
|
|
||||||
|
/** Внутренние имена полей списка «Объекты». */
|
||||||
|
const OBJECT_FIELDS = {
|
||||||
|
title: 'Title',
|
||||||
|
/** Текстовое поле с названием проекта (не lookup). */
|
||||||
|
project: 'project',
|
||||||
|
/** Self-lookup на родителя: читается как `parent/Id`, пишется как `parentId`. */
|
||||||
|
parentWrite: 'parentId'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Заголовки записи — те же, что в старом коде (verbose OData). */
|
||||||
|
const VERBOSE_HEADERS: Record<string, string> = {
|
||||||
|
Accept: 'application/json;odata=verbose',
|
||||||
|
'Content-Type': 'application/json;odata=verbose',
|
||||||
|
'odata-version': ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const MERGE_HEADERS: Record<string, string> = {
|
||||||
|
...VERBOSE_HEADERS,
|
||||||
|
'IF-MATCH': '*',
|
||||||
|
'X-HTTP-Method': 'MERGE'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DELETE_HEADERS: Record<string, string> = {
|
||||||
|
'IF-MATCH': '*',
|
||||||
|
'X-HTTP-Method': 'DELETE'
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedObjectsList = (): string => encodeURIComponent(LISTS.objects);
|
||||||
|
|
||||||
|
export function projectTitleUrl(siteUrl: string, projectId: string): string {
|
||||||
|
return `${listItemsUrl(siteUrl, encodeURIComponent(LISTS.projects))}(${projectId})?$select=Id,Title`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectsByProjectUrl(siteUrl: string, projectTitle: string): string {
|
||||||
|
const filter = encodeURIComponent(`${OBJECT_FIELDS.project} eq '${projectTitle.replace(/'/g, "''")}'`);
|
||||||
|
return (
|
||||||
|
`${listItemsUrl(siteUrl, encodedObjectsList())}` +
|
||||||
|
`?$select=Id,Title,parent/Id&$expand=parent&$filter=${filter}&$top=${OBJECTS_LIMIT}&$orderby=Title`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accessUrl(siteUrl: string, projectId: string, userId: number): string {
|
||||||
|
return (
|
||||||
|
`${listItemsUrl(siteUrl, LISTS.access)}` +
|
||||||
|
`?$select=Id,edit0&$top=1` +
|
||||||
|
`&$filter=projectId eq ${projectId} and userId eq ${userId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectsListItemsUrl(siteUrl: string): string {
|
||||||
|
return listItemsUrl(siteUrl, encodedObjectsList());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectItemUrl(siteUrl: string, itemId: number): string {
|
||||||
|
return `${objectsListItemsUrl(siteUrl)}(${itemId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectsEntityTypeUrl(siteUrl: string): string {
|
||||||
|
return `${siteUrl}/_api/web/lists/getbytitle('${encodedObjectsList()}')?$select=ListItemEntityTypeFullName`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectObjectsExportUrl(apiBaseUrl: string, projectId: string): string {
|
||||||
|
return `${apiBaseUrl}/Report/ProjectObjects?projectId=${projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toProjectObject(row: IObjectRow): IProjectObject {
|
||||||
|
return { id: row.Id, title: row.Title, parentId: row.parent?.Id || undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст ошибки записи: `error.message.value` (verbose) → `error.message` → statusText — как в старом коде. */
|
||||||
|
async function readWriteError(response: IHttpResponse): Promise<string> {
|
||||||
|
const body = (await response.json().catch(() => undefined)) as ISpErrorBody | undefined;
|
||||||
|
const message = body?.error?.message;
|
||||||
|
const value = typeof message === 'object' ? message.value : undefined;
|
||||||
|
return value || (message ? String(message) : '') || response.statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectsRepositoryDeps {
|
||||||
|
sharePoint: IHttpClient;
|
||||||
|
tnsuApi: IHttpClient;
|
||||||
|
siteUrl: string;
|
||||||
|
apiBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectTitle(deps: IObjectsRepositoryDeps, projectId: string): Promise<string> {
|
||||||
|
const response = await deps.sharePoint.fetch(projectTitleUrl(deps.siteUrl, projectId));
|
||||||
|
if (!response.ok) throw new Error(OBJECTS_TEXTS.projectNotFound(projectId));
|
||||||
|
const data = (await response.json()) as { Title: string };
|
||||||
|
return data.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchObjects(deps: IObjectsRepositoryDeps, projectTitle: string): Promise<IProjectObject[]> {
|
||||||
|
const response = await deps.sharePoint.fetch(objectsByProjectUrl(deps.siteUrl, projectTitle));
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => undefined)) as ISpErrorBody | undefined;
|
||||||
|
const message = body?.error?.message;
|
||||||
|
throw new Error(message ? String(message) : OBJECTS_TEXTS.listLoadError(LISTS.objects));
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as Partial<ISpCollection<IObjectRow>>;
|
||||||
|
return (data.value ?? []).map(toProjectObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Право редактирования — первая запись «Доступы» проекта и пользователя с edit0. Любая ошибка — нет права. */
|
||||||
|
async function fetchCanEdit(deps: IObjectsRepositoryDeps, projectId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const userId = await fetchCurrentUserId(deps.sharePoint, deps.siteUrl);
|
||||||
|
const response = await deps.sharePoint.fetch(accessUrl(deps.siteUrl, projectId, userId));
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = (await response.json()) as ISpCollection<{ Id: number; edit0: boolean }>;
|
||||||
|
return data.value.length > 0 && data.value[0].edit0 === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тип элементов списка для __metadata. Ответ не проверяется на ok — как в старом коде. */
|
||||||
|
async function fetchEntityType(deps: IObjectsRepositoryDeps): Promise<string | undefined> {
|
||||||
|
const response = await deps.sharePoint.fetch(objectsEntityTypeUrl(deps.siteUrl));
|
||||||
|
const data = (await response.json()) as { ListItemEntityTypeFullName?: string } | undefined;
|
||||||
|
return data?.ListItemEntityTypeFullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postVerbose(
|
||||||
|
deps: IObjectsRepositoryDeps,
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
body: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await deps.sharePoint.fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readWriteError(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteObject(deps: IObjectsRepositoryDeps, itemId: number): Promise<void> {
|
||||||
|
const response = await deps.sharePoint.fetch(objectItemUrl(deps.siteUrl, itemId), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: DELETE_HEADERS
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(response.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadObjectsExcel(deps: IObjectsRepositoryDeps, projectId: string): Promise<Blob> {
|
||||||
|
const response = await deps.tnsuApi.fetch(projectObjectsExportUrl(deps.apiBaseUrl, projectId));
|
||||||
|
if (!response.ok) throw new Error(OBJECTS_TEXTS.serverError(response.status));
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectsRepository {
|
||||||
|
fetchProjectTitle(projectId: string): Promise<string>;
|
||||||
|
fetchObjects(projectTitle: string): Promise<IProjectObject[]>;
|
||||||
|
fetchCanEdit(projectId: string): Promise<boolean>;
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий хранит кэш типа элементов списка (ListItemEntityTypeFullName) —
|
||||||
|
* как старый web part: тип запрашивается один раз, перед первой записью.
|
||||||
|
*/
|
||||||
|
export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsRepository {
|
||||||
|
let entityTypePromise: Promise<string | undefined> | undefined;
|
||||||
|
|
||||||
|
// Неудачный запрос не кэшируется — следующая запись спросит тип снова, как в старом коде.
|
||||||
|
const getEntityType = (): Promise<string | undefined> => {
|
||||||
|
if (!entityTypePromise) {
|
||||||
|
entityTypePromise = fetchEntityType(deps).catch((error: unknown) => {
|
||||||
|
entityTypePromise = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entityTypePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = async (): Promise<{ __metadata: { type: string | undefined } }> => ({
|
||||||
|
__metadata: { type: await getEntityType() }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchProjectTitle: (projectId) => fetchProjectTitle(deps, projectId),
|
||||||
|
fetchObjects: (projectTitle) => fetchObjects(deps, projectTitle),
|
||||||
|
fetchCanEdit: (projectId) => fetchCanEdit(deps, projectId),
|
||||||
|
createObject: async (projectTitle, title, parentId) => {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
...(await metadata()),
|
||||||
|
[OBJECT_FIELDS.title]: title,
|
||||||
|
[OBJECT_FIELDS.project]: projectTitle
|
||||||
|
};
|
||||||
|
if (parentId !== undefined) body[OBJECT_FIELDS.parentWrite] = parentId;
|
||||||
|
await postVerbose(deps, objectsListItemsUrl(deps.siteUrl), VERBOSE_HEADERS, body);
|
||||||
|
},
|
||||||
|
updateObject: async (itemId, title, newParentId) => {
|
||||||
|
const body: Record<string, unknown> = { ...(await metadata()), [OBJECT_FIELDS.title]: title };
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
buildObjectTree,
|
||||||
|
canAddChild,
|
||||||
|
canDrop,
|
||||||
|
getChangedParentId,
|
||||||
|
getDeleteConfirmMessage,
|
||||||
|
getErrorText,
|
||||||
|
getModalTitle,
|
||||||
|
getParentDisplay,
|
||||||
|
getParentOptions,
|
||||||
|
getSubmitLabel,
|
||||||
|
isParentSelectable,
|
||||||
|
resolveMove
|
||||||
|
} from './objectsRules';
|
||||||
|
import { buildObjectMenuItems } from './objectMenu';
|
||||||
|
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
|
||||||
|
|
||||||
|
// 1 Корпус Б (Объект-пятно)
|
||||||
|
// ├─ 2 Секция (Раздел)
|
||||||
|
// │ └─ 4 Бетон (Вид работ)
|
||||||
|
// └─ 3 Кровля (Раздел)
|
||||||
|
// 5 Корпус А (Объект-пятно)
|
||||||
|
const OBJECTS: IProjectObject[] = [
|
||||||
|
{ id: 1, title: 'Корпус Б', parentId: undefined },
|
||||||
|
{ id: 2, title: 'Секция', parentId: 1 },
|
||||||
|
{ id: 3, title: 'Кровля', parentId: 1 },
|
||||||
|
{ id: 4, title: 'Бетон', parentId: 2 },
|
||||||
|
{ id: 5, title: 'Корпус А', parentId: undefined }
|
||||||
|
];
|
||||||
|
|
||||||
|
const tree: ObjectTree = buildObjectTree(OBJECTS);
|
||||||
|
const node = (id: number): ObjectNode => {
|
||||||
|
const found = tree.byId.get(id);
|
||||||
|
if (!found) throw new Error(`нет узла ${id}`);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
const target = (mode: ObjectModalMode, id?: number): { mode: ObjectModalMode; node: ObjectNode | undefined } => ({
|
||||||
|
mode,
|
||||||
|
node: id === undefined ? undefined : node(id)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('уровни и меню', () => {
|
||||||
|
it('добавлять дочерние можно в Объект-пятно и Раздел, но не в Вид работ', () => {
|
||||||
|
expect([1, 2, 4].map((id) => canAddChild(node(id)))).toEqual([true, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('меню узла: Добавить / Редактировать / Работы / Удалить, у Вида работ — без «Добавить»', () => {
|
||||||
|
expect(buildObjectMenuItems(node(1)).map((item) => item.label)).toEqual([
|
||||||
|
'Добавить объект',
|
||||||
|
'Редактировать',
|
||||||
|
'Работы',
|
||||||
|
'Удалить'
|
||||||
|
]);
|
||||||
|
expect(buildObjectMenuItems(node(4)).map((item) => item.key)).toEqual(['edit', 'works', 'delete']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('модалка', () => {
|
||||||
|
it('заголовки по режиму и уровню', () => {
|
||||||
|
expect(getModalTitle(target('addRoot'))).toEqual('Добавить Объект-пятно');
|
||||||
|
expect(getModalTitle(target('add', 1))).toEqual('Добавить Раздел');
|
||||||
|
expect(getModalTitle(target('add', 2))).toEqual('Добавить Вид работ');
|
||||||
|
expect(getModalTitle(target('add', 4))).toEqual('Добавить объект');
|
||||||
|
expect(getModalTitle(target('edit', 1))).toEqual('Редактировать Объект-пятно');
|
||||||
|
expect(getModalTitle(target('edit', 2))).toEqual('Редактировать Раздел');
|
||||||
|
expect(getModalTitle(target('edit', 4))).toEqual('Редактировать Вид работ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('кнопка: «Добавить» при добавлении, «Сохранить» при правке', () => {
|
||||||
|
expect(getSubmitLabel(target('addRoot'))).toEqual('Добавить');
|
||||||
|
expect(getSubmitLabel(target('add', 1))).toEqual('Добавить');
|
||||||
|
expect(getSubmitLabel(target('edit', 1))).toEqual('Сохранить');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('родителя можно выбирать только при правке Раздела', () => {
|
||||||
|
expect(isParentSelectable(target('edit', 2))).toBe(true);
|
||||||
|
expect(isParentSelectable(target('edit', 1))).toBe(false);
|
||||||
|
expect(isParentSelectable(target('edit', 4))).toBe(false);
|
||||||
|
expect(isParentSelectable(target('add', 2))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('родитель в режиме просмотра: при добавлении — сам узел, при правке — родитель или «— (корневой)»', () => {
|
||||||
|
expect(getParentDisplay(target('add', 2), tree)).toEqual('Секция');
|
||||||
|
expect(getParentDisplay(target('edit', 4), tree)).toEqual('Секция');
|
||||||
|
expect(getParentDisplay(target('edit', 1), tree)).toEqual('— (корневой)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('кандидаты в родители Раздела — Объекты-пятна по алфавиту', () => {
|
||||||
|
expect(getParentOptions(node(2), tree)).toEqual([
|
||||||
|
{ value: '5', label: 'Корпус А' },
|
||||||
|
{ value: '1', label: 'Корпус Б' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('новый родитель — только если выбран другой', () => {
|
||||||
|
expect(getChangedParentId(node(2), '5')).toEqual(5);
|
||||||
|
expect(getChangedParentId(node(2), '1')).toBeUndefined();
|
||||||
|
expect(getChangedParentId(node(2), '')).toBeUndefined();
|
||||||
|
expect(getChangedParentId(node(4), '5')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
'Удалить объект «Корпус Б»?\n\n⚠️ У этого объекта есть дочерние элементы (2 шт.).\n\nЭто действие нельзя отменить.'
|
||||||
|
);
|
||||||
|
expect(getDeleteConfirmMessage(node(4))).toEqual(
|
||||||
|
'Удалить объект «Бетон»?\n\nЭто действие нельзя отменить.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без названия — «ID n»', () => {
|
||||||
|
const untitled = buildObjectTree([{ id: 8, title: '', parentId: undefined }]);
|
||||||
|
const untitledNode = untitled.byId.get(8);
|
||||||
|
expect(untitledNode && getDeleteConfirmMessage(untitledNode)).toContain('«ID 8»');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('текст ошибки — message для Error, иначе строка', () => {
|
||||||
|
expect(getErrorText(new Error('сбой'))).toEqual('сбой');
|
||||||
|
expect(getErrorText('текст')).toEqual('текст');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
|
import { buildTree, isDescendant } from '../_tree/treeRules';
|
||||||
|
import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
|
||||||
|
|
||||||
|
const TEXTS = OBJECTS_TEXTS.modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уровни дерева: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ.
|
||||||
|
* Добавлять дочерние можно только в узлы уровней 0 и 1 — дерево не глубже трёх уровней.
|
||||||
|
*/
|
||||||
|
export const SECTION_LEVEL = 1;
|
||||||
|
const MAX_PARENT_LEVEL = 1;
|
||||||
|
|
||||||
|
export function buildObjectTree(objects: IProjectObject[]): ObjectTree {
|
||||||
|
return buildTree(
|
||||||
|
objects,
|
||||||
|
(object) => object.id,
|
||||||
|
(object) => object.parentId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пункт «Добавить объект» в меню узла. */
|
||||||
|
export function canAddChild(node: ObjectNode): boolean {
|
||||||
|
return node.level <= MAX_PARENT_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModalTitle({ mode, node }: IObjectModalTarget): string {
|
||||||
|
const level = node?.level ?? 0;
|
||||||
|
if (mode === 'addRoot') return TEXTS.addRootTitle;
|
||||||
|
if (mode === 'add') return TEXTS.addTitle(TEXTS.childLabels[level] ?? TEXTS.fallbackLabel);
|
||||||
|
return TEXTS.editTitle(TEXTS.levelLabels[level] ?? TEXTS.fallbackLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubmitLabel({ mode }: IObjectModalTarget): string {
|
||||||
|
return mode === 'edit' ? TEXTS.save : TEXTS.add;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** У Раздела при правке родителя можно сменить (select); у остальных — только просмотр. */
|
||||||
|
export function isParentSelectable({ mode, node }: IObjectModalTarget): boolean {
|
||||||
|
return mode === 'edit' && node?.level === SECTION_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст поля «Родительский объект» в режиме просмотра:
|
||||||
|
* при добавлении — сам узел (или «—»), при правке — его родитель (или «— (корневой)»).
|
||||||
|
*/
|
||||||
|
export function getParentDisplay({ mode, node }: IObjectModalTarget, tree: ObjectTree): string {
|
||||||
|
if (mode === 'add') return node?.item.title || TEXTS.parentEmpty;
|
||||||
|
const parent = node?.item.parentId ? tree.byId.get(node.item.parentId) : undefined;
|
||||||
|
return parent ? parent.item.title : TEXTS.parentRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кандидаты в родители Раздела: Объекты-пятна, кроме самого узла и его потомков, по алфавиту (ru). */
|
||||||
|
export function getParentOptions(node: ObjectNode, tree: ObjectTree): ISelectOption[] {
|
||||||
|
const candidates: ObjectNode[] = [];
|
||||||
|
tree.byId.forEach((candidate) => {
|
||||||
|
if (candidate.level === 0 && candidate.id !== node.id && !isDescendant(tree.byId, node.id, candidate.id)) {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
candidates.sort((a, b) => a.item.title.localeCompare(b.item.title, 'ru'));
|
||||||
|
return candidates.map((candidate) => ({ value: String(candidate.id), label: candidate.item.title }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Новый родитель Раздела из select — только если он выбран и изменился; иначе поле не трогаем. */
|
||||||
|
export function getChangedParentId(node: ObjectNode | undefined, selectValue: string): number | undefined {
|
||||||
|
if (!node || node.level !== SECTION_LEVEL || !selectValue) return undefined;
|
||||||
|
const parentId = parseInt(selectValue, 10);
|
||||||
|
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),
|
||||||
|
node.children.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст исключения — как `err instanceof Error ? err.message : String(err)` в старом коде. */
|
||||||
|
export function getErrorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ITree, ITreeNode } from '../_tree/treeRules';
|
||||||
|
|
||||||
|
/** Элемент списка «Объекты». */
|
||||||
|
export interface IProjectObject {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/** Id родителя (lookup `parent`); undefined — корневой объект. */
|
||||||
|
parentId: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObjectNode = ITreeNode<IProjectObject, number>;
|
||||||
|
export type ObjectTree = ITree<IProjectObject, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим модалки: addRoot — новый Объект-пятно, add — дочерний объект узла, edit — правка узла.
|
||||||
|
*/
|
||||||
|
export type ObjectModalMode = 'add' | 'edit' | 'addRoot';
|
||||||
|
|
||||||
|
export interface IObjectModalTarget {
|
||||||
|
mode: ObjectModalMode;
|
||||||
|
/** Узел, для которого открыта модалка; для addRoot — undefined. */
|
||||||
|
node: ObjectNode | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Куда бросили узел относительно целевого: до, внутрь, после. */
|
||||||
|
export type DropPosition = 'before' | 'inside' | 'after';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
|
||||||
|
* Пустой lookup приходит как null — читается через `?.`.
|
||||||
|
*/
|
||||||
|
export interface IObjectRow {
|
||||||
|
Id: number;
|
||||||
|
Title: string;
|
||||||
|
parent?: { Id?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ответ SharePoint с ошибкой: verbose — message.value, иначе — строка message. */
|
||||||
|
export interface ISpErrorBody {
|
||||||
|
error?: { message?: string | { value?: string } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Тексты страницы «Объекты» — дословно из stroy-bot-objects (ObjectsByProjectWebPart.ts).
|
||||||
|
|
||||||
|
export const OBJECTS_TEXTS = {
|
||||||
|
header: {
|
||||||
|
label: 'Объекты проекта',
|
||||||
|
titleLoading: 'Загрузка...',
|
||||||
|
titleError: 'Ошибка',
|
||||||
|
addRoot: 'Добавить объект',
|
||||||
|
addRootTooltip: 'Добавить корневой объект',
|
||||||
|
export: 'Экспорт',
|
||||||
|
exporting: 'Экспортируется...',
|
||||||
|
exportTooltip: 'Скачать в Excel'
|
||||||
|
},
|
||||||
|
loading: 'Загружаем данные...',
|
||||||
|
noProject: 'Вы не выбрали проект',
|
||||||
|
projectNotFound: (projectId: string): string => `Проект с ID=${projectId} не найден`,
|
||||||
|
listLoadError: (listName: string): string => `Ошибка загрузки списка "${listName}"`,
|
||||||
|
loadError: (message: string): string => `Ошибка: ${message}`,
|
||||||
|
empty: 'Объекты не найдены',
|
||||||
|
emptyAdd: '+ Добавить объект',
|
||||||
|
toolbar: {
|
||||||
|
total: (count: number): string => `${count} объектов`,
|
||||||
|
expandAll: '↕ Развернуть',
|
||||||
|
collapseAll: '↙ Свернуть'
|
||||||
|
},
|
||||||
|
nodeMenuTooltip: 'Действия',
|
||||||
|
menu: {
|
||||||
|
add: 'Добавить объект',
|
||||||
|
edit: 'Редактировать',
|
||||||
|
works: 'Работы',
|
||||||
|
delete: 'Удалить'
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
addRootTitle: 'Добавить Объект-пятно',
|
||||||
|
addTitle: (label: string): string => `Добавить ${label}`,
|
||||||
|
editTitle: (label: string): string => `Редактировать ${label}`,
|
||||||
|
/** Что добавляется в узел уровня N. */
|
||||||
|
childLabels: ['Раздел', 'Вид работ'] as readonly string[],
|
||||||
|
/** Как называется узел уровня N. */
|
||||||
|
levelLabels: ['Объект-пятно', 'Раздел', 'Вид работ'] as readonly string[],
|
||||||
|
fallbackLabel: 'объект',
|
||||||
|
titleLabel: 'Название',
|
||||||
|
titlePlaceholder: 'Введите название объекта',
|
||||||
|
titleRequired: 'Поле обязательно для заполнения',
|
||||||
|
parentLabel: 'Родительский объект',
|
||||||
|
parentEmpty: '—',
|
||||||
|
parentRoot: '— (корневой)',
|
||||||
|
projectLabel: 'Проект',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
add: 'Добавить',
|
||||||
|
save: 'Сохранить'
|
||||||
|
},
|
||||||
|
deleteConfirm: (title: string, childCount: number): string => {
|
||||||
|
const childWarning =
|
||||||
|
childCount > 0 ? `\n\n⚠️ У этого объекта есть дочерние элементы (${childCount} шт.).` : '';
|
||||||
|
return `Удалить объект «${title}»?${childWarning}\n\nЭто действие нельзя отменить.`;
|
||||||
|
},
|
||||||
|
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;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
interface IActionsDeps {
|
||||||
|
repository: IObjectsRepository;
|
||||||
|
projectTitle: string | undefined;
|
||||||
|
tree: ObjectTree;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectActions {
|
||||||
|
/** Ошибка переноса или удаления (раньше — 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 {
|
||||||
|
const [actionError, setActionError] = React.useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
|
||||||
|
const { mode, node } = target;
|
||||||
|
// Как в старом коде: объект привязывается к проекту по названию (текстовое поле project).
|
||||||
|
const project = projectTitle ?? '';
|
||||||
|
if (mode === 'addRoot') {
|
||||||
|
await repository.createObject(project, title, undefined);
|
||||||
|
} else if (mode === 'add' && node) {
|
||||||
|
await repository.createObject(project, title, node.id);
|
||||||
|
} else if (node) {
|
||||||
|
await repository.updateObject(node.id, title, getChangedParentId(node, parentValue));
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
confirmText: OBJECTS_TEXTS.menu.delete,
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
setActionError(undefined);
|
||||||
|
try {
|
||||||
|
await repository.deleteObject(node.id);
|
||||||
|
reload();
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(OBJECTS_TEXTS.deleteError(getErrorText(error)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { actionError, saveObject, moveObject, deleteObject };
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { collectParentIds, toggleId } from '../_tree/treeRules';
|
||||||
|
import { ObjectNode, ObjectTree } from './objectsTypes';
|
||||||
|
|
||||||
|
export interface IObjectTreeView {
|
||||||
|
expandedIds: Set<number>;
|
||||||
|
selectedId: number | undefined;
|
||||||
|
/** Шеврон: только раскрыть/свернуть. */
|
||||||
|
toggle: (nodeId: number) => void;
|
||||||
|
/** Клик по строке: выделить и, если есть дети, раскрыть/свернуть. */
|
||||||
|
clickRow: (node: ObjectNode) => void;
|
||||||
|
select: (nodeId: number) => void;
|
||||||
|
expandAll: () => void;
|
||||||
|
collapseAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрытые и выделенный узлы. Живут, пока открыта страница, и переживают перезагрузку
|
||||||
|
* дерева после любой операции — как поля _expandedNodes / _selectedNodeId старого web part.
|
||||||
|
*/
|
||||||
|
export function useObjectTreeView(tree: ObjectTree): IObjectTreeView {
|
||||||
|
const [expandedIds, setExpandedIds] = React.useState<Set<number>>(() => new Set<number>());
|
||||||
|
const [selectedId, setSelectedId] = React.useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const toggle = (nodeId: number): void => setExpandedIds((prev) => toggleId(prev, nodeId));
|
||||||
|
|
||||||
|
const clickRow = (node: ObjectNode): void => {
|
||||||
|
setSelectedId(node.id);
|
||||||
|
if (node.children.length > 0) toggle(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAll = (): void => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
collectParentIds(tree.roots).forEach((id) => next.add(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAll = (): void => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
collectParentIds(tree.roots).forEach((id) => next.delete(id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { expandedIds, selectedId, toggle, clickRow, select: setSelectedId, expandAll, collapseAll };
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IObjectsRepository } from './objectsRepository';
|
||||||
|
import { buildObjectTree, getErrorText } from './objectsRules';
|
||||||
|
import { IProjectObject, ObjectTree } from './objectsTypes';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export type ObjectsLoadStatus = 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
interface IObjectsState {
|
||||||
|
status: ObjectsLoadStatus;
|
||||||
|
errorMessage: string | undefined;
|
||||||
|
projectTitle: string | undefined;
|
||||||
|
canEdit: boolean;
|
||||||
|
objects: IProjectObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IObjectsData extends IObjectsState {
|
||||||
|
tree: ObjectTree;
|
||||||
|
/** Перечитать проект, права и объекты. Текущее дерево остаётся на экране до ответа — как в старом коде. */
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: IObjectsState = {
|
||||||
|
status: 'loading',
|
||||||
|
errorMessage: undefined,
|
||||||
|
projectTitle: undefined,
|
||||||
|
canEdit: false,
|
||||||
|
objects: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка страницы в порядке stroy-bot-objects: название проекта → права → объекты.
|
||||||
|
* Название и права показываются сразу, как пришли, даже если объекты потом не загрузятся.
|
||||||
|
*/
|
||||||
|
export function useObjectsData(repository: IObjectsRepository, projectId: string | undefined): IObjectsData {
|
||||||
|
const [state, setState] = React.useState<IObjectsState>(INITIAL_STATE);
|
||||||
|
const [reloadKey, setReloadKey] = React.useState<number>(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let isCancelled = false;
|
||||||
|
const update = (patch: Partial<IObjectsState>): void => {
|
||||||
|
if (!isCancelled) setState((prev) => ({ ...prev, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
if (!projectId) {
|
||||||
|
update({ status: 'error', errorMessage: OBJECTS_TEXTS.noProject });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const projectTitle = await repository.fetchProjectTitle(projectId);
|
||||||
|
update({ projectTitle });
|
||||||
|
update({ canEdit: await repository.fetchCanEdit(projectId) });
|
||||||
|
const objects = await repository.fetchObjects(projectTitle);
|
||||||
|
update({ status: 'ready', errorMessage: undefined, objects });
|
||||||
|
} catch (error) {
|
||||||
|
update({ status: 'error', errorMessage: OBJECTS_TEXTS.loadError(getErrorText(error)) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load().catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [repository, projectId, reloadKey]);
|
||||||
|
|
||||||
|
const tree = React.useMemo(() => buildObjectTree(state.objects), [state.objects]);
|
||||||
|
const reload = React.useCallback(() => setReloadKey((key) => key + 1), []);
|
||||||
|
|
||||||
|
return { ...state, tree, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { saveFile } from '../../shared/utils/saveFile';
|
||||||
|
import { IObjectsRepository } from './objectsRepository';
|
||||||
|
import { getErrorText } from './objectsRules';
|
||||||
|
import { OBJECTS_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export interface IObjectsExport {
|
||||||
|
isExporting: boolean;
|
||||||
|
/** Ошибка выгрузки (раньше — alert). */
|
||||||
|
exportError: string | undefined;
|
||||||
|
exportExcel: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопка «Экспорт»: Excel со всеми объектами проекта из корпоративного API — `objects-{projectId}.xlsx`. */
|
||||||
|
export function useObjectsExport(repository: IObjectsRepository, projectId: string | undefined): IObjectsExport {
|
||||||
|
const [isExporting, setIsExporting] = React.useState<boolean>(false);
|
||||||
|
const [exportError, setExportError] = React.useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const exportExcel = async (): Promise<void> => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
setExportError(undefined);
|
||||||
|
try {
|
||||||
|
const blob = await repository.downloadObjectsExcel(projectId);
|
||||||
|
saveFile(blob, `objects-${projectId}.xlsx`);
|
||||||
|
} catch (error) {
|
||||||
|
setExportError(OBJECTS_TEXTS.exportError(getErrorText(error)));
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isExporting, exportError, exportExcel };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user