refactor: remove drag and drop from the objects tree

Decided during manual testing: the legacy drop-inside zone had zero
height, so drops only worked before/after a node and moves happened by
accident. Sections are re-parented via Edit, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-19 10:07:06 +05:00
parent 8ed1a02967
commit d2852df976
13 changed files with 17 additions and 276 deletions
@@ -1,32 +0,0 @@
import * as React from 'react';
import { DropPosition } from './objectsTypes';
import { IObjectDrag } from './useObjectDrag';
import styles from './ObjectsPage.module.scss';
interface Props {
targetId: number;
position: DropPosition;
drag: IObjectDrag;
}
const POSITION_CLASS: Record<DropPosition, string> = {
before: styles.dropZoneEdge,
inside: styles.dropZoneInside,
after: styles.dropZoneEdge
};
/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */
export const DropZone: React.FC<Props> = ({ targetId, position, drag }) => {
const isActive = drag.activeZone?.targetId === targetId && drag.activeZone.position === position;
const className = [styles.dropZone, POSITION_CLASS[position], isActive ? styles.dropZoneActive : '']
.filter(Boolean)
.join(' ');
return (
<div
className={className}
onDragOver={(event) => drag.onZoneDragOver({ targetId, position }, event)}
onDragLeave={drag.onZoneDragLeave}
/>
);
};
@@ -3,8 +3,6 @@ import { Icon } from '@tnsu/ui-kit';
import { IMenuAnchor } from '../_tree/ContextMenu';
import { ObjectNode } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { DropZone } from './DropZone';
import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss';
@@ -12,7 +10,6 @@ interface Props {
node: ObjectNode;
canEdit: boolean;
view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
}
@@ -20,19 +17,14 @@ interface Props {
const ROW_BASE_INDENT_PX = 12;
const ROW_LEVEL_INDENT_PX = 22;
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string {
return [
styles.nodeRow,
hasChildren ? styles.nodeRowParent : '',
isSelected ? styles.nodeRowSelected : '',
isDragging ? styles.nodeRowDragging : ''
]
function rowClassName(hasChildren: boolean, isSelected: boolean): string {
return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
.filter(Boolean)
.join(' ');
}
/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onOpenMenu }) => {
/** Узел дерева объектов и (если раскрыт) его дочерние узлы. */
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, onOpenMenu }) => {
const hasChildren = node.children.length > 0;
const isExpanded = view.expandedIds.has(node.id);
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
return (
<div>
<DropZone targetId={node.id} position="before" drag={drag} />
<div
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === node.id)}
className={rowClassName(hasChildren, view.selectedId === node.id)}
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
draggable={canEdit}
onDragStart={(event) => drag.onDragStart(node.id, event)}
onDragEnd={drag.onDragEnd}
onClick={() => view.clickRow(node)}
onContextMenu={onContextMenu}
>
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
</button>
)}
</div>
<DropZone targetId={node.id} position="inside" drag={drag} />
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<ObjectTreeNode
key={child.id}
node={child}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
<ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
))}
</div>
)}
<DropZone targetId={node.id} position="after" drag={drag} />
</div>
);
};
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
}
}
.nodeRowDragging {
opacity: 0.45;
}
.nodeToggle {
display: flex;
align-items: center;
@@ -182,32 +178,6 @@ $menu-button-size: 28px;
}
}
// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
// Высоты 1:1 со старым web part: зона «внутрь» нулевой высоты (см. открытые вопросы в чек-листе).
.dropZone {
margin: 0 $sp-2;
border-radius: 1px;
transition: background 0.12s, height 0.12s;
}
.dropZoneEdge {
height: 2px;
&.dropZoneActive {
height: 3px;
background: $gold-400;
}
}
.dropZoneInside {
height: 0;
&.dropZoneActive {
height: 2px;
background: rgba($gold-400, 0.3);
}
}
// ── Модалка ─────────────────────────────────────────────────────────────────
.modalBody {
display: flex;
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
import { useObjectActions } from './useObjectActions';
import { useObjectsExport } from './useObjectsExport';
import { useObjectTreeView } from './useObjectTreeView';
import { useObjectDrag } from './useObjectDrag';
import { ObjectsHeader } from './ObjectsHeader';
import { ObjectsTree } from './ObjectsTree';
import { ObjectModal } from './ObjectModal';
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
);
const data = useObjectsData(repository, projectId);
const actions = useObjectActions({
repository,
projectTitle: data.projectTitle,
tree: data.tree,
reload: data.reload
});
const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
const exporter = useObjectsExport(repository, projectId);
const view = useObjectTreeView(data.tree);
const drag = useObjectDrag(data.tree, (dragId, targetId, position) => {
actions.moveObject(dragId, targetId, position).catch(() => undefined);
});
const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined);
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
tree={data.tree}
canEdit={data.canEdit}
view={view}
drag={drag}
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
onAddRoot={openAddRoot}
/>
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
import { countNodes } from '../_tree/treeRules';
import { ObjectNode, ObjectTree } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { ObjectTreeNode } from './ObjectTreeNode';
import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss';
@@ -14,13 +13,12 @@ interface Props {
tree: ObjectTree;
canEdit: boolean;
view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
onAddRoot: () => void;
}
/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => (
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, onOpenMenu, onAddRoot }) => (
<>
<div className={styles.toolbar}>
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
@@ -48,16 +46,9 @@ export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpen
/>
)}
<div className={styles.tree} onDrop={drag.onDrop}>
<div className={styles.tree}>
{tree.roots.map((node) => (
<ObjectTreeNode
key={node.id}
node={node}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
<ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
))}
</div>
</>
@@ -174,17 +174,6 @@ describe('запись', () => {
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
});
it('перенос: меняется только parentId, в корень — null', async () => {
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
await repository.moveObject(7, 2);
await repository.moveObject(7, undefined);
expect(sharePoint.requests[1].request.headers).toEqual(MERGE_HEADERS);
expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: 2 });
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: null });
});
it('ошибка записи: error.message.value → error.message → statusText', async () => {
const verbose = setup([
ENTITY_TYPE_ROUTE,
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
/** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
moveObject(itemId: number, newParentId: number | undefined): Promise<void>;
deleteObject(itemId: number): Promise<void>;
downloadObjectsExcel(projectId: string): Promise<Blob>;
}
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
},
moveObject: async (itemId, newParentId) => {
// Перенос в корень сбрасывает lookup явным null.
const body = { ...(await metadata()), [OBJECT_FIELDS.parentWrite]: newParentId ?? null };
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
},
deleteObject: (itemId) => deleteObject(deps, itemId),
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
};
@@ -1,7 +1,6 @@
import {
buildObjectTree,
canAddChild,
canDrop,
getChangedParentId,
getDeleteConfirmMessage,
getErrorText,
@@ -9,8 +8,7 @@ import {
getParentDisplay,
getParentOptions,
getSubmitLabel,
isParentSelectable,
resolveMove
isParentSelectable
} from './objectsRules';
import { buildObjectMenuItems } from './objectMenu';
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
@@ -100,26 +98,6 @@ describe('модалка', () => {
});
});
describe('перетаскивание', () => {
it('нельзя бросить в себя и в своих потомков', () => {
expect(canDrop(tree, 1, 1)).toBe(false);
expect(canDrop(tree, 1, 4)).toBe(false);
expect(canDrop(tree, 4, 1)).toBe(true);
});
it('«внутрь» — родитель = цель, «до/после» — родитель цели', () => {
expect(resolveMove(tree, 4, 3, 'inside')).toEqual({ parentId: 3 });
expect(resolveMove(tree, 4, 3, 'before')).toEqual({ parentId: 1 });
expect(resolveMove(tree, 2, 5, 'after')).toEqual({ parentId: undefined });
});
it('родитель не меняется или перенос запрещён — ничего не делаем', () => {
expect(resolveMove(tree, 2, 3, 'after')).toBeUndefined();
expect(resolveMove(tree, 1, 5, 'before')).toBeUndefined();
expect(resolveMove(tree, 1, 4, 'inside')).toBeUndefined();
});
});
describe('удаление и ошибки', () => {
it('подтверждение с числом дочерних', () => {
expect(getDeleteConfirmMessage(node(1))).toEqual(
@@ -1,6 +1,6 @@
import { ISelectOption } from '@tnsu/ui-kit';
import { buildTree, isDescendant } from '../_tree/treeRules';
import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
import { IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts';
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
@@ -72,33 +72,6 @@ export function getChangedParentId(node: ObjectNode | undefined, selectValue: st
return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
}
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
export function canDrop(tree: ObjectTree, dragId: number, targetId: number): boolean {
return dragId !== targetId && !isDescendant(tree.byId, dragId, targetId);
}
export interface IMoveResult {
/** undefined — перенос в корень. */
parentId: number | undefined;
}
/**
* Новый родитель после перетаскивания: «внутрь» — целевой узел, «до/после» — родитель целевого.
* undefined — переносить нечего (родитель не меняется или перенос запрещён).
*/
export function resolveMove(
tree: ObjectTree,
dragId: number,
targetId: number,
position: DropPosition
): IMoveResult | undefined {
const dragNode = tree.byId.get(dragId);
if (!dragNode || !canDrop(tree, dragId, targetId)) return undefined;
const parentId = position === 'inside' ? targetId : tree.byId.get(targetId)?.item.parentId;
return dragNode.item.parentId === parentId ? undefined : { parentId };
}
export function getDeleteConfirmMessage(node: ObjectNode): string {
return OBJECTS_TEXTS.deleteConfirm(
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
node: ObjectNode | undefined;
}
/** Куда бросили узел относительно целевого: до, внутрь, после. */
export type DropPosition = 'before' | 'inside' | 'after';
/**
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
* Пустой lookup приходит как null — читается через `?.`.
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
deleteFallbackTitle: (id: number): string => `ID ${id}`,
serverError: (status: number): string => `Ошибка сервера: ${status}`,
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
moveError: (message: string): string => `Не удалось переместить: ${message}`,
deleteError: (message: string): string => `Не удалось удалить: ${message}`,
exportError: (message: string): string => `Не удалось скачать файл: ${message}`
} as const;
@@ -1,28 +1,26 @@
import * as React from 'react';
import { confirmModal } from '@tnsu/ui-kit';
import { IObjectsRepository } from './objectsRepository';
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules';
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes';
import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
import { IObjectModalTarget, ObjectNode } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts';
interface IActionsDeps {
repository: IObjectsRepository;
projectTitle: string | undefined;
tree: ObjectTree;
reload: () => void;
}
export interface IObjectActions {
/** Ошибка переноса или удаления (раньше — alert). */
/** Ошибка удаления (раньше — alert). */
actionError: string | undefined;
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise<void>;
deleteObject: (node: ObjectNode) => Promise<void>;
}
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions {
export function useObjectActions({ repository, projectTitle, reload }: IActionsDeps): IObjectActions {
const [actionError, setActionError] = React.useState<string | undefined>(undefined);
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
reload();
};
const moveObject = async (dragId: number, targetId: number, position: DropPosition): Promise<void> => {
const move = resolveMove(tree, dragId, targetId, position);
if (!move) return;
setActionError(undefined);
try {
await repository.moveObject(dragId, move.parentId);
reload();
} catch (error) {
setActionError(OBJECTS_TEXTS.moveError(getErrorText(error)));
}
};
const deleteObject = async (node: ObjectNode): Promise<void> => {
const isConfirmed = await confirmModal({
message: getDeleteConfirmMessage(node),
@@ -67,5 +53,5 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
}
};
return { actionError, saveObject, moveObject, deleteObject };
return { actionError, saveObject, deleteObject };
}
@@ -1,72 +0,0 @@
import * as React from 'react';
import { canDrop } from './objectsRules';
import { DropPosition, ObjectTree } from './objectsTypes';
export interface IDropZone {
targetId: number;
position: DropPosition;
}
export interface IObjectDrag {
draggingId: number | undefined;
/** Подсвеченная зона сброса. */
activeZone: IDropZone | undefined;
onDragStart: (nodeId: number, event: React.DragEvent) => void;
onDragEnd: () => void;
onZoneDragOver: (zone: IDropZone, event: React.DragEvent) => void;
onZoneDragLeave: () => void;
onDrop: (event: React.DragEvent) => void;
}
/**
* Перетаскивание узлов — как в stroy-bot-objects: узел бросается на зону до / внутрь / после
* другого узла. Бросок засчитывается в последнюю «разрешённую» зону, над которой был курсор.
*/
export function useObjectDrag(
tree: ObjectTree,
onMove: (dragId: number, targetId: number, position: DropPosition) => void
): IObjectDrag {
const [draggingId, setDraggingId] = React.useState<number | undefined>(undefined);
const [activeZone, setActiveZone] = React.useState<IDropZone | undefined>(undefined);
// Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
const dropTargetRef = React.useRef<IDropZone | undefined>(undefined);
const reset = (): void => {
setDraggingId(undefined);
setActiveZone(undefined);
dropTargetRef.current = undefined;
};
const onDragStart = (nodeId: number, event: React.DragEvent): void => {
setDraggingId(nodeId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(nodeId));
};
const onZoneDragOver = (zone: IDropZone, event: React.DragEvent): void => {
if (draggingId === undefined || !canDrop(tree, draggingId, zone.targetId)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setActiveZone(zone);
dropTargetRef.current = zone;
};
const onDrop = (event: React.DragEvent): void => {
event.preventDefault();
const dragId = draggingId;
const target = dropTargetRef.current;
reset();
if (dragId === undefined || !target) return;
onMove(dragId, target.targetId, target.position);
};
return {
draggingId,
activeZone,
onDragStart,
onDragEnd: reset,
onZoneDragOver,
onZoneDragLeave: () => setActiveZone(undefined),
onDrop
};
}