F: Implement drag change hierarchy
Some checks failed
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions
Backend CI / build (3.12) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-04-23 23:19:37 +03:00
parent 5e6b6fff5b
commit c25f22f340
12 changed files with 225 additions and 37 deletions

View File

@ -8,6 +8,7 @@ from .data_access import (
CreateOperationSerializer,
DeleteBlockSerializer,
DeleteOperationSerializer,
MoveItemsSerializer,
OperationSchemaSerializer,
OperationSerializer,
RelocateConstituentsSerializer,

View File

@ -132,6 +132,37 @@ class DeleteBlockSerializer(serializers.Serializer):
return attrs
class MoveItemsSerializer(serializers.Serializer):
''' Serializer: Move items to another parent. '''
layout = LayoutSerializer()
operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent'))
blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent'))
destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True)
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
parent_block = cast(Block, attrs['destination'])
if parent_block is not None and parent_block.oss_id != oss.pk:
raise serializers.ValidationError({
'destination': msg.blockNotInOSS()
})
for operation in attrs['operations']:
if operation.oss_id != oss.pk:
raise serializers.ValidationError({
'operations': msg.operationNotInOSS()
})
for block in attrs['blocks']:
if parent_block is not None and block.pk == parent_block.pk:
raise serializers.ValidationError({
'destination': msg.blockSelfParent()
})
if block.oss_id != oss.pk:
raise serializers.ValidationError({
'blocks': msg.blockNotInOSS()
})
return attrs
class CreateOperationSerializer(serializers.Serializer):
''' Serializer: Operation creation. '''
class CreateOperationData(serializers.ModelSerializer):

View File

@ -55,12 +55,13 @@ class TestOssViewset(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
layout = self.owned.layout()
layout.data = {'operations': [
self.layout_data = {'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
], 'blocks': []}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
self.owned.set_arguments(self.operation3.pk, [self.operation1, self.operation2])
@ -167,6 +168,43 @@ class TestOssViewset(EndpointTester):
self.assertEqual(response.data['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
@decl_endpoint('/api/oss/{item}/move-items', method='patch')
def test_move_items(self):
self.populateData()
self.executeBadData(item=self.owned_id)
block1 = self.owned.create_block(title='1')
block2 = self.owned.create_block(title='2')
data = {
'layout': self.layout_data,
'blocks': [block2.pk],
'operations': [self.operation1.pk, self.operation2.pk],
'destination': block2.pk
}
self.executeBadData(data=data)
data['destination'] = block1.pk
self.executeOK(data=data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
block2.refresh_from_db()
self.assertEqual(self.operation1.parent.pk, block1.pk)
self.assertEqual(self.operation2.parent.pk, block1.pk)
self.assertEqual(block2.parent.pk, block1.pk)
data['destination'] = None
self.executeOK(data=data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
block2.refresh_from_db()
self.assertEqual(block2.parent, None)
self.assertEqual(self.operation1.parent, None)
self.assertEqual(self.operation2.parent, None)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents(self):
self.populateData()

View File

@ -40,6 +40,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_block',
'update_block',
'delete_block',
'move_items',
'create_operation',
'update_operation',
'delete_operation',
@ -214,6 +215,41 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='move items',
tags=['OSS'],
request=s.MoveItemsSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='move-items')
def move_items(self, request: Request, pk) -> HttpResponse:
''' Move items to another parent. '''
serializer = s.MoveItemsSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_layout(serializer.validated_data['layout'])
for operation in serializer.validated_data['operations']:
operation.parent = serializer.validated_data['destination']
operation.save(update_fields=['parent'])
for block in serializer.validated_data['blocks']:
block.parent = serializer.validated_data['destination']
block.save(update_fields=['parent'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='create operation',
tags=['OSS'],

View File

@ -12,6 +12,7 @@ import {
type IDeleteBlockDTO,
type IDeleteOperationDTO,
type IInputCreatedResponse,
type IMoveItemsDTO,
type IOperationCreatedResponse,
type IOperationSchemaDTO,
type IOssLayout,
@ -138,6 +139,16 @@ export const ossApi = {
}
}),
moveItems: ({ itemID, data }: { itemID: number; data: IMoveItemsDTO }) =>
axiosPatch<IMoveItemsDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/move-items`,
request: {
data: data,
successMessage: infoMsg.moveSuccess
}
}),
relocateConstituents: (data: IRelocateConstituentsDTO) =>
axiosPost<IRelocateConstituentsDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,

View File

@ -39,6 +39,9 @@ export type IUpdateBlockDTO = z.infer<typeof schemaUpdateBlock>;
/** Represents {@link IBlock} data, used in Delete action. */
export type IDeleteBlockDTO = z.infer<typeof schemaDeleteBlock>;
/** Represents data, used to move {@link IOssItem} to another parent. */
export type IMoveItemsDTO = z.infer<typeof schemaMoveItems>;
/** Represents {@link IOperation} data, used in Create action. */
export type ICreateOperationDTO = z.infer<typeof schemaCreateOperation>;
@ -208,6 +211,13 @@ export const schemaDeleteOperation = z.strictObject({
delete_schema: z.boolean()
});
export const schemaMoveItems = z.strictObject({
layout: schemaOssLayout,
operations: z.array(z.number()),
blocks: z.array(z.number()),
destination: z.number().nullable()
});
export const schemaUpdateInput = z.strictObject({
target: z.number(),
layout: schemaOssLayout,

View File

@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IMoveItemsDTO } from './types';
export const useMoveItems = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'move-items'],
mutationFn: ossApi.moveItems,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([
client.invalidateQueries({ queryKey: KEYS.composite.libraryList }),
client.invalidateQueries({ queryKey: [KEYS.rsform] })
]);
},
onError: () => client.invalidateQueries()
});
return {
moveItems: (data: { itemID: number; data: IMoveItemsDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -18,7 +18,7 @@ export const BLOCK_NODE_MIN_HEIGHT = 100;
export function BlockNode(node: BlockInternalNode) {
const { selected, schema } = useOssEdit();
const { dropTarget } = useOssFlow();
const { dropTarget, isDragging } = useOssFlow();
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const setHover = useOperationTooltipStore(state => state.setHoverItem);
@ -44,8 +44,8 @@ export function BlockNode(node: BlockInternalNode) {
<div
className={clsx(
'cc-node-block h-full w-full',
dropTarget && isParent && dropTarget !== node.data.block.id && 'border-destructive',
((isParent && !dropTarget) || dropTarget === node.data.block.id) && 'border-primary',
isDragging && isParent && dropTarget !== node.data.block.id && 'border-destructive',
((isParent && !isDragging) || dropTarget === node.data.block.id) && 'border-primary',
isChild && 'border-accent-orange50'
)}
>

View File

@ -3,6 +3,8 @@
import { createContext, use } from 'react';
interface IOssFlowContext {
isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
dropTarget: number | null;
setDropTarget: React.Dispatch<React.SetStateAction<number | null>>;
containMovement: boolean;

View File

@ -5,12 +5,16 @@ import { useState } from 'react';
import { OssFlowContext } from './oss-flow-context';
export const OssFlowState = ({ children }: React.PropsWithChildren) => {
const [isDragging, setIsDragging] = useState(false);
const [dropTarget, setDropTarget] = useState<number | null>(null);
const [containMovement, setContainMovement] = useState(false);
return (
<OssFlowContext
value={{
isDragging,
setIsDragging,
dropTarget,
setDropTarget,

View File

@ -14,6 +14,7 @@ import {
import clsx from 'clsx';
import { useDeleteBlock } from '@/features/oss/backend/use-delete-block';
import { useMoveItems } from '@/features/oss/backend/use-move-items';
import { type IOperationSchema } from '@/features/oss/models/oss';
import { useMainHeight } from '@/stores/app-layout';
@ -54,7 +55,7 @@ export function OssFlow() {
canDeleteOperation: canDelete
} = useOssEdit();
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow();
const { setDropTarget, setContainMovement, containMovement } = useOssFlow();
const { setDropTarget, setContainMovement, containMovement, setIsDragging } = useOssFlow();
const store = useStoreApi();
const { resetSelectedElements, addSelectedNodes } = store.getState();
@ -70,6 +71,7 @@ export function OssFlow() {
const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout();
const { deleteBlock } = useDeleteBlock();
const { moveItems } = useMoveItems();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
@ -265,6 +267,35 @@ export function OssFlow() {
setMouseCoords(targetPosition);
}
function determineDropTarget(event: React.MouseEvent): number | null {
const mousePosition = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const blocks = getIntersectingNodes({
x: mousePosition.x,
y: mousePosition.y,
width: 1,
height: 1
})
.map(node => Number(node.id))
.filter(id => id < 0 && !selected.includes(id))
.map(id => schema.blockByID.get(-id))
.filter(block => !!block);
if (blocks.length === 0) {
return null;
}
if (blocks.length === 1) {
return blocks[0].id;
}
const parents = blocks.map(block => block.parent).filter(id => !!id);
const potentialTargets = blocks.map(block => block.id).filter(id => !parents.includes(id));
if (potentialTargets.length === 0) {
return null;
} else {
return potentialTargets[0];
}
}
function handleDragStart(event: React.MouseEvent, target: Node) {
if (event.shiftKey) {
setContainMovement(true);
@ -281,6 +312,7 @@ export function OssFlow() {
);
} else {
setContainMovement(false);
setDropTarget(determineDropTarget(event));
}
setIsContextMenuOpen(false);
}
@ -289,38 +321,11 @@ export function OssFlow() {
if (containMovement) {
return;
}
const mousePosition = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const blocks = getIntersectingNodes({
x: mousePosition.x,
y: mousePosition.y,
width: 1,
height: 1
})
.map(node => Number(node.id))
.filter(id => id < 0)
.map(id => schema.blockByID.get(-id))
.filter(block => !!block);
if (blocks.length === 0) {
setDropTarget(null);
return;
} else if (blocks.length === 1) {
setDropTarget(blocks[0].id);
return;
}
const parents = blocks.map(block => block.parent).filter(id => !!id);
const potentialTargets = blocks.map(block => block.id).filter(id => !parents.includes(id));
if (potentialTargets.length === 0) {
setDropTarget(null);
return;
} else {
setDropTarget(potentialTargets[0]);
return;
}
setIsDragging(true);
setDropTarget(determineDropTarget(event));
}
function handleDragStop(_: React.MouseEvent, target: Node) {
function handleDragStop(event: React.MouseEvent, target: Node) {
if (containMovement) {
setNodes(prev =>
prev.map(node =>
@ -334,8 +339,32 @@ export function OssFlow() {
)
);
} else {
// TODO: process drop event
const new_parent = determineDropTarget(event);
const allSelected = [...selected.filter(id => id != Number(target.id)), Number(target.id)];
const operations = allSelected
.filter(id => id > 0)
.map(id => schema.operationByID.get(id))
.filter(operation => !!operation);
const blocks = allSelected
.filter(id => id < 0)
.map(id => schema.blockByID.get(-id))
.filter(operation => !!operation);
const parents = [...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(
id => !!id
);
if ((parents.length !== 1 || parents[0] !== new_parent) && (parents.length !== 0 || new_parent !== null)) {
void moveItems({
itemID: schema.id,
data: {
layout: getLayout(),
operations: operations.map(operation => operation.id),
blocks: blocks.map(block => block.id),
destination: new_parent
}
});
}
}
setIsDragging(false);
setContainMovement(false);
setDropTarget(null);
}

View File

@ -25,6 +25,7 @@ export const infoMsg = {
substitutionsCorrect: 'Таблица отождествлений прошла проверку',
uploadSuccess: 'Схема загружена из файла',
inlineSynthesisComplete: 'Встраивание завершено',
moveSuccess: 'Перемещение завершено',
newLibraryItem: 'Схема успешно создана',
addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,