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