From c25f22f340e526ae536a5efa53238823a293e850 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Wed, 23 Apr 2025 23:19:37 +0300 Subject: [PATCH] F: Implement drag change hierarchy --- .../backend/apps/oss/serializers/__init__.py | 1 + .../apps/oss/serializers/data_access.py | 31 +++++++ .../backend/apps/oss/tests/s_views/t_oss.py | 42 ++++++++- rsconcept/backend/apps/oss/views/oss.py | 36 +++++++ .../frontend/src/features/oss/backend/api.ts | 11 +++ .../src/features/oss/backend/types.ts | 10 ++ .../features/oss/backend/use-move-items.tsx | 25 +++++ .../editor-oss-graph/graph/block-node.tsx | 6 +- .../editor-oss-graph/oss-flow-context.tsx | 2 + .../editor-oss-graph/oss-flow-state.tsx | 4 + .../oss-page/editor-oss-graph/oss-flow.tsx | 93 ++++++++++++------- rsconcept/frontend/src/utils/labels.ts | 1 + 12 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 rsconcept/frontend/src/features/oss/backend/use-move-items.tsx diff --git a/rsconcept/backend/apps/oss/serializers/__init__.py b/rsconcept/backend/apps/oss/serializers/__init__.py index c965d3a6..03bd35ae 100644 --- a/rsconcept/backend/apps/oss/serializers/__init__.py +++ b/rsconcept/backend/apps/oss/serializers/__init__.py @@ -8,6 +8,7 @@ from .data_access import ( CreateOperationSerializer, DeleteBlockSerializer, DeleteOperationSerializer, + MoveItemsSerializer, OperationSchemaSerializer, OperationSerializer, RelocateConstituentsSerializer, diff --git a/rsconcept/backend/apps/oss/serializers/data_access.py b/rsconcept/backend/apps/oss/serializers/data_access.py index ac038843..a95c51a5 100644 --- a/rsconcept/backend/apps/oss/serializers/data_access.py +++ b/rsconcept/backend/apps/oss/serializers/data_access.py @@ -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): diff --git a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py index a43b632f..6fbaf791 100644 --- a/rsconcept/backend/apps/oss/tests/s_views/t_oss.py +++ b/rsconcept/backend/apps/oss/tests/s_views/t_oss.py @@ -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() diff --git a/rsconcept/backend/apps/oss/views/oss.py b/rsconcept/backend/apps/oss/views/oss.py index 7b3ed019..e99d2746 100644 --- a/rsconcept/backend/apps/oss/views/oss.py +++ b/rsconcept/backend/apps/oss/views/oss.py @@ -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'], diff --git a/rsconcept/frontend/src/features/oss/backend/api.ts b/rsconcept/frontend/src/features/oss/backend/api.ts index 02e09ed3..1fffb566 100644 --- a/rsconcept/frontend/src/features/oss/backend/api.ts +++ b/rsconcept/frontend/src/features/oss/backend/api.ts @@ -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({ + schema: schemaOperationSchema, + endpoint: `/api/oss/${itemID}/move-items`, + request: { + data: data, + successMessage: infoMsg.moveSuccess + } + }), + relocateConstituents: (data: IRelocateConstituentsDTO) => axiosPost({ schema: schemaOperationSchema, diff --git a/rsconcept/frontend/src/features/oss/backend/types.ts b/rsconcept/frontend/src/features/oss/backend/types.ts index c74d3b6d..eee8d75a 100644 --- a/rsconcept/frontend/src/features/oss/backend/types.ts +++ b/rsconcept/frontend/src/features/oss/backend/types.ts @@ -39,6 +39,9 @@ export type IUpdateBlockDTO = z.infer; /** Represents {@link IBlock} data, used in Delete action. */ export type IDeleteBlockDTO = z.infer; +/** Represents data, used to move {@link IOssItem} to another parent. */ +export type IMoveItemsDTO = z.infer; + /** Represents {@link IOperation} data, used in Create action. */ export type ICreateOperationDTO = z.infer; @@ -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, diff --git a/rsconcept/frontend/src/features/oss/backend/use-move-items.tsx b/rsconcept/frontend/src/features/oss/backend/use-move-items.tsx new file mode 100644 index 00000000..23c521e7 --- /dev/null +++ b/rsconcept/frontend/src/features/oss/backend/use-move-items.tsx @@ -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) + }; +}; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx index d3b76bc7..b59e4aa0 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx @@ -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) {
diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx index 2171ddfb..71f0bfc9 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx @@ -3,6 +3,8 @@ import { createContext, use } from 'react'; interface IOssFlowContext { + isDragging: boolean; + setIsDragging: React.Dispatch>; dropTarget: number | null; setDropTarget: React.Dispatch>; containMovement: boolean; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx index 980122e2..a74e0487 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx @@ -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(null); const [containMovement, setContainMovement] = useState(false); return ( 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); } diff --git a/rsconcept/frontend/src/utils/labels.ts b/rsconcept/frontend/src/utils/labels.ts index 1c76c912..cbf2a174 100644 --- a/rsconcept/frontend/src/utils/labels.ts +++ b/rsconcept/frontend/src/utils/labels.ts @@ -25,6 +25,7 @@ export const infoMsg = { substitutionsCorrect: 'Таблица отождествлений прошла проверку', uploadSuccess: 'Схема загружена из файла', inlineSynthesisComplete: 'Встраивание завершено', + moveSuccess: 'Перемещение завершено', newLibraryItem: 'Схема успешно создана', addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,