mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-25 20:40:36 +03:00
F: Implement drag change hierarchy
This commit is contained in:
parent
5e6b6fff5b
commit
c25f22f340
|
@ -8,6 +8,7 @@ from .data_access import (
|
|||
CreateOperationSerializer,
|
||||
DeleteBlockSerializer,
|
||||
DeleteOperationSerializer,
|
||||
MoveItemsSerializer,
|
||||
OperationSchemaSerializer,
|
||||
OperationSerializer,
|
||||
RelocateConstituentsSerializer,
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
|
@ -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'
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ export const infoMsg = {
|
|||
substitutionsCorrect: 'Таблица отождествлений прошла проверку',
|
||||
uploadSuccess: 'Схема загружена из файла',
|
||||
inlineSynthesisComplete: 'Встраивание завершено',
|
||||
moveSuccess: 'Перемещение завершено',
|
||||
|
||||
newLibraryItem: 'Схема успешно создана',
|
||||
addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,
|
||||
|
|
Loading…
Reference in New Issue
Block a user