F: Implement operation and schema delete consequence for OSS
Some checks are pending
Backend CI / build (3.12) (push) Waiting to run
Frontend CI / build (22.x) (push) Waiting to run

This commit is contained in:
Ivan 2024-08-15 23:23:45 +03:00
parent 5eb63eac42
commit 35883458f3
31 changed files with 315 additions and 66 deletions

View File

@ -123,3 +123,11 @@ class LibraryItem(Model):
def versions(self) -> QuerySet[Version]: def versions(self) -> QuerySet[Version]:
''' Get all Versions of this item. ''' ''' Get all Versions of this item. '''
return Version.objects.filter(item=self.pk).order_by('-time_create') return Version.objects.filter(item=self.pk).order_by('-time_create')
def is_synced(self, target: 'LibraryItem') -> bool:
''' Check if item is synced with target. '''
if self.owner != target.owner:
return False
if self.location != target.location:
return False
return True

View File

@ -217,13 +217,10 @@ class TestLibraryViewset(EndpointTester):
@decl_endpoint('/api/library/{item}', method='delete') @decl_endpoint('/api/library/{item}', method='delete')
def test_destroy(self): def test_destroy(self):
response = self.execute(item=self.owned.pk) self.executeNoContent(item=self.owned.pk)
self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT])
self.executeForbidden(item=self.unowned.pk) self.executeForbidden(item=self.unowned.pk)
self.toggle_admin(True) self.toggle_admin(True)
response = self.execute(item=self.unowned.pk) self.executeNoContent(item=self.unowned.pk)
self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT])
@decl_endpoint('/api/library/active', method='get') @decl_endpoint('/api/library/active', method='get')

View File

@ -13,7 +13,7 @@ from rest_framework.decorators import action
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from apps.oss.models import Operation, OperationSchema from apps.oss.models import Operation, OperationSchema, PropagationFacade
from apps.rsform.models import RSForm from apps.rsform.models import RSForm
from apps.rsform.serializers import RSFormParseSerializer from apps.rsform.serializers import RSFormParseSerializer
from apps.users.models import User from apps.users.models import User
@ -67,6 +67,10 @@ class LibraryViewSet(viewsets.ModelViewSet):
if update_list: if update_list:
Operation.objects.bulk_update(update_list, ['alias', 'title', 'comment']) Operation.objects.bulk_update(update_list, ['alias', 'title', 'comment'])
def perform_destroy(self, instance: m.LibraryItem) -> None:
PropagationFacade.before_delete_schema(instance)
return super().perform_destroy(instance)
def get_permissions(self): def get_permissions(self):
if self.action in ['update', 'partial_update']: if self.action in ['update', 'partial_update']:
access_level = permissions.ItemEditor access_level = permissions.ItemEditor

View File

@ -100,7 +100,7 @@ class OperationSchema:
if not keep_constituents: if not keep_constituents:
schema = self.cache.get_schema(target) schema = self.cache.get_schema(target)
if schema is not None: if schema is not None:
self.before_delete(schema.cache.constituents, schema) self.before_delete_cst(schema.cache.constituents, schema)
self.cache.remove_operation(target.pk) self.cache.remove_operation(target.pk)
target.delete() target.delete()
self.save(update_fields=['time_update']) self.save(update_fields=['time_update'])
@ -115,7 +115,7 @@ class OperationSchema:
if old_schema is not None: if old_schema is not None:
if has_children: if has_children:
self.before_delete(old_schema.cache.constituents, old_schema) self.before_delete_cst(old_schema.cache.constituents, old_schema)
self.cache.remove_schema(old_schema) self.cache.remove_schema(old_schema)
operation.result = schema operation.result = schema
@ -280,7 +280,7 @@ class OperationSchema:
mapping=alias_mapping mapping=alias_mapping
) )
def before_delete(self, target: list[Constituenta], source: RSForm) -> None: def before_delete_cst(self, target: list[Constituenta], source: RSForm) -> None:
''' Trigger cascade resolutions before constituents are deleted. ''' ''' Trigger cascade resolutions before constituents are deleted. '''
self.cache.insert(source) self.cache.insert(source)
operation = self.cache.get_operation(source.model.pk) operation = self.cache.get_operation(source.model.pk)

View File

@ -1,5 +1,5 @@
''' Models: Change propagation facade - managing all changes in OSS. ''' ''' Models: Change propagation facade - managing all changes in OSS. '''
from apps.library.models import LibraryItem from apps.library.models import LibraryItem, LibraryItemType
from apps.rsform.models import Constituenta, RSForm from apps.rsform.models import Constituenta, RSForm
from .OperationSchema import CstSubstitution, OperationSchema from .OperationSchema import CstSubstitution, OperationSchema
@ -35,11 +35,11 @@ class PropagationFacade:
OperationSchema(host).after_update_cst(target, data, old_data, source) OperationSchema(host).after_update_cst(target, data, old_data, source)
@staticmethod @staticmethod
def before_delete(target: list[Constituenta], source: RSForm) -> None: def before_delete_cst(target: list[Constituenta], source: RSForm) -> None:
''' Trigger cascade resolutions before constituents are deleted. ''' ''' Trigger cascade resolutions before constituents are deleted. '''
hosts = _get_oss_hosts(source.model) hosts = _get_oss_hosts(source.model)
for host in hosts: for host in hosts:
OperationSchema(host).before_delete(target, source) OperationSchema(host).before_delete_cst(target, source)
@staticmethod @staticmethod
def before_substitute(substitutions: CstSubstitution, source: RSForm) -> None: def before_substitute(substitutions: CstSubstitution, source: RSForm) -> None:
@ -47,3 +47,15 @@ class PropagationFacade:
hosts = _get_oss_hosts(source.model) hosts = _get_oss_hosts(source.model)
for host in hosts: for host in hosts:
OperationSchema(host).before_substitute(substitutions, source) OperationSchema(host).before_substitute(substitutions, source)
@staticmethod
def before_delete_schema(item: LibraryItem) -> None:
''' Trigger cascade resolutions before schema is deleted. '''
if item.item_type != LibraryItemType.RSFORM:
return
hosts = _get_oss_hosts(item)
if len(hosts) == 0:
return
schema = RSForm(item)
PropagationFacade.before_delete_cst(list(schema.constituents()), schema)

View File

@ -4,6 +4,7 @@ from .basics import OperationPositionSerializer, PositionsSerializer, Substituti
from .data_access import ( from .data_access import (
ArgumentSerializer, ArgumentSerializer,
OperationCreateSerializer, OperationCreateSerializer,
OperationDeleteSerializer,
OperationSchemaSerializer, OperationSchemaSerializer,
OperationSerializer, OperationSerializer,
OperationTargetSerializer, OperationTargetSerializer,

View File

@ -138,6 +138,26 @@ class OperationTargetSerializer(serializers.Serializer):
return attrs return attrs
class OperationDeleteSerializer(serializers.Serializer):
''' Serializer: Delete operation. '''
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result'))
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]
)
keep_constituents = serializers.BooleanField(default=False, required=False)
delete_schema = serializers.BooleanField(default=False, required=False)
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target'])
if oss and operation.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title)
})
return attrs
class SetOperationInputSerializer(serializers.Serializer): class SetOperationInputSerializer(serializers.Serializer):
''' Serializer: Set input schema for operation. ''' ''' Serializer: Set input schema for operation. '''
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all())

View File

@ -191,3 +191,61 @@ class TestChangeOperations(EndpointTester):
self.assertEqual(self.ks4D1.definition_formal, r'X4 X1') self.assertEqual(self.ks4D1.definition_formal, r'X4 X1')
self.assertEqual(self.ks4D2.definition_formal, r'X1 DEL DEL DEL D1') self.assertEqual(self.ks4D2.definition_formal, r'X1 DEL DEL DEL D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 DEL DEL DEL D1 D2 D3') self.assertEqual(self.ks5D4.definition_formal, r'X1 DEL DEL DEL D1 D2 D3')
@decl_endpoint('/api/library/{item}', method='delete')
def test_delete_schema(self):
self.executeNoContent(item=self.ks1.model.pk)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, None)
subs1_2 = self.operation4.getSubstitutions()
self.assertEqual(subs1_2.count(), 0)
subs3_4 = self.operation5.getSubstitutions()
self.assertEqual(subs3_4.count(), 0)
self.assertEqual(self.ks4.constituents().count(), 4)
self.assertEqual(self.ks5.constituents().count(), 7)
self.assertEqual(self.ks4D2.definition_formal, r'DEL X2 X3 S1 DEL')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 S1 D1 DEL D3')
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation_and_constituents(self):
data = {
'positions': [],
'target': self.operation1.pk,
'keep_constituents': False,
'delete_schema': True
}
self.executeOK(data=data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getSubstitutions()
self.assertEqual(subs1_2.count(), 0)
subs3_4 = self.operation5.getSubstitutions()
self.assertEqual(subs3_4.count(), 0)
self.assertEqual(self.ks4.constituents().count(), 4)
self.assertEqual(self.ks5.constituents().count(), 7)
self.assertEqual(self.ks4D2.definition_formal, r'DEL X2 X3 S1 DEL')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 S1 D1 DEL D3')
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation_keep_constituents(self):
data = {
'positions': [],
'target': self.operation1.pk,
'keep_constituents': True,
'delete_schema': True
}
self.executeOK(data=data, item=self.owned_id)
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
subs1_2 = self.operation4.getSubstitutions()
self.assertEqual(subs1_2.count(), 0)
subs3_4 = self.operation5.getSubstitutions()
self.assertEqual(subs3_4.count(), 1)
self.assertEqual(self.ks4.constituents().count(), 6)
self.assertEqual(self.ks5.constituents().count(), 8)
self.assertEqual(self.ks4D2.definition_formal, r'X1 X2 X3 S1 D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 S1 D1 D2 D3')

View File

@ -349,6 +349,8 @@ class TestOssViewset(EndpointTester):
} }
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data=data, item=self.owned_id)
self.ks2.model.visible = False
self.ks2.model.save(update_fields=['visible'])
data = { data = {
'positions': [], 'positions': [],
'target': self.operation2.pk, 'target': self.operation2.pk,
@ -356,7 +358,9 @@ class TestOssViewset(EndpointTester):
} }
self.executeOK(data=data, item=self.owned_id) self.executeOK(data=data, item=self.owned_id)
self.operation2.refresh_from_db() self.operation2.refresh_from_db()
self.ks2.model.refresh_from_db()
self.assertEqual(self.operation2.result, None) self.assertEqual(self.operation2.result, None)
self.assertEqual(self.ks2.model.visible, True)
data = { data = {
'positions': [], 'positions': [],

View File

@ -143,7 +143,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@extend_schema( @extend_schema(
summary='delete operation', summary='delete operation',
tags=['OSS'], tags=['OSS'],
request=s.OperationTargetSerializer, request=s.OperationDeleteSerializer,
responses={ responses={
c.HTTP_200_OK: s.OperationSchemaSerializer, c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
@ -154,20 +154,25 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@action(detail=True, methods=['patch'], url_path='delete-operation') @action(detail=True, methods=['patch'], url_path='delete-operation')
def delete_operation(self, request: Request, pk) -> HttpResponse: def delete_operation(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete operation. ''' ''' Endpoint: Delete operation. '''
serializer = s.OperationTargetSerializer( serializer = s.OperationDeleteSerializer(
data=request.data, data=request.data,
context={'oss': self.get_object()} context={'oss': self.get_object()}
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
oss = m.OperationSchema(self.get_object()) oss = m.OperationSchema(self.get_object())
operation = cast(m.Operation, serializer.validated_data['target'])
old_schema: Optional[LibraryItem] = operation.result
with transaction.atomic(): with transaction.atomic():
oss.update_positions(serializer.validated_data['positions']) oss.update_positions(serializer.validated_data['positions'])
oss.delete_operation(operation, serializer.validated_data['keep_constituents'])
# TODO: propagate changes to RSForms if old_schema is not None:
if serializer.validated_data['delete_schema']:
oss.delete_operation(serializer.validated_data['target']) m.PropagationFacade.before_delete_schema(old_schema)
old_schema.delete()
elif old_schema.is_synced(oss.model):
old_schema.visible = True
old_schema.save(update_fields=['visible'])
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data data=s.OperationSchemaSerializer(oss.model).data
@ -249,9 +254,13 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
raise serializers.ValidationError({ raise serializers.ValidationError({
'input': msg.operationInputAlreadyConnected() 'input': msg.operationInputAlreadyConnected()
}) })
oss = m.OperationSchema(self.get_object()) oss = m.OperationSchema(self.get_object())
old_schema: Optional[LibraryItem] = target_operation.result
with transaction.atomic(): with transaction.atomic():
if old_schema is not None:
if old_schema.is_synced(oss.model):
old_schema.visible = True
old_schema.save(update_fields=['visible'])
oss.update_positions(serializer.validated_data['positions']) oss.update_positions(serializer.validated_data['positions'])
oss.set_input(target_operation.pk, schema) oss.set_input(target_operation.pk, schema)
return Response( return Response(

View File

@ -263,7 +263,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
cst_list: list[m.Constituenta] = serializer.validated_data['items'] cst_list: list[m.Constituenta] = serializer.validated_data['items']
schema = m.RSForm(model) schema = m.RSForm(model)
with transaction.atomic(): with transaction.atomic():
PropagationFacade.before_delete(cst_list, schema) PropagationFacade.before_delete_cst(cst_list, schema)
schema.delete_cst(cst_list) schema.delete_cst(cst_list)
return Response( return Response(
status=c.HTTP_200_OK, status=c.HTTP_200_OK,

View File

@ -6,6 +6,7 @@ import {
IInputCreatedResponse, IInputCreatedResponse,
IOperationCreateData, IOperationCreateData,
IOperationCreatedResponse, IOperationCreatedResponse,
IOperationDeleteData,
IOperationSchemaData, IOperationSchemaData,
IOperationSetInputData, IOperationSetInputData,
IOperationUpdateData, IOperationUpdateData,
@ -40,7 +41,7 @@ export function postCreateOperation(
}); });
} }
export function patchDeleteOperation(oss: string, request: FrontExchange<ITargetOperation, IOperationSchemaData>) { export function patchDeleteOperation(oss: string, request: FrontExchange<IOperationDeleteData, IOperationSchemaData>) {
AxiosPatch({ AxiosPatch({
endpoint: `/api/oss/${oss}/delete-operation`, endpoint: `/api/oss/${oss}/delete-operation`,
request: request request: request

View File

@ -67,6 +67,11 @@ function TooltipOperation({ node, anchor }: TooltipOperationProps) {
<p> <p>
<b>Тип:</b> {labelOperationType(node.data.operation.operation_type)} <b>Тип:</b> {labelOperationType(node.data.operation.operation_type)}
</p> </p>
{!node.data.operation.is_owned ? (
<p>
<b>КС не принадлежит ОСС</b>
</p>
) : null}
{node.data.operation.title ? ( {node.data.operation.title ? (
<p> <p>
<b>Название: </b> <b>Название: </b>

View File

@ -27,7 +27,7 @@ function Checkbox({
}: CheckboxProps) { }: CheckboxProps) {
const cursor = useMemo(() => { const cursor = useMemo(() => {
if (disabled) { if (disabled) {
return 'cursor-auto'; return 'cursor-arrow';
} else if (setValue) { } else if (setValue) {
return 'cursor-pointer'; return 'cursor-pointer';
} else { } else {

View File

@ -25,7 +25,7 @@ function CheckboxTristate({
}: CheckboxTristateProps) { }: CheckboxTristateProps) {
const cursor = useMemo(() => { const cursor = useMemo(() => {
if (disabled) { if (disabled) {
return 'cursor-auto'; return 'cursor-arrow';
} else if (setValue) { } else if (setValue) {
return 'cursor-pointer'; return 'cursor-pointer';
} else { } else {

View File

@ -91,7 +91,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
setSchema: setGlobalOSS, setSchema: setGlobalOSS,
loading: ossLoading, loading: ossLoading,
reload: reloadOssInternal reload: reloadOssInternal
} = useOssDetails({ target: ossID }); } = useOssDetails({ target: ossID, items: items });
const reloadOSS = useCallback( const reloadOSS = useCallback(
(callback?: () => void) => { (callback?: () => void) => {

View File

@ -27,6 +27,7 @@ import { ILibraryUpdateData } from '@/models/library';
import { import {
IOperationCreateData, IOperationCreateData,
IOperationData, IOperationData,
IOperationDeleteData,
IOperationSchema, IOperationSchema,
IOperationSchemaData, IOperationSchemaData,
IOperationSetInputData, IOperationSetInputData,
@ -63,7 +64,7 @@ interface IOssContext {
savePositions: (data: IPositionsData, callback?: () => void) => void; savePositions: (data: IPositionsData, callback?: () => void) => void;
createOperation: (data: IOperationCreateData, callback?: DataCallback<IOperationData>) => void; createOperation: (data: IOperationCreateData, callback?: DataCallback<IOperationData>) => void;
deleteOperation: (data: ITargetOperation, callback?: () => void) => void; deleteOperation: (data: IOperationDeleteData, callback?: () => void) => void;
createInput: (data: ITargetOperation, callback?: DataCallback<ILibraryItem>) => void; createInput: (data: ITargetOperation, callback?: DataCallback<ILibraryItem>) => void;
setInput: (data: IOperationSetInputData, callback?: () => void) => void; setInput: (data: IOperationSetInputData, callback?: () => void) => void;
updateOperation: (data: IOperationUpdateData, callback?: () => void) => void; updateOperation: (data: IOperationUpdateData, callback?: () => void) => void;
@ -309,7 +310,7 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
); );
const deleteOperation = useCallback( const deleteOperation = useCallback(
(data: ITargetOperation, callback?: () => void) => { (data: IOperationDeleteData, callback?: () => void) => {
setProcessingError(undefined); setProcessingError(undefined);
patchDeleteOperation(itemID, { patchDeleteOperation(itemID, {
data: data, data: data,

View File

@ -31,10 +31,6 @@ function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeIn
setSelected(newValue); setSelected(newValue);
}, []); }, []);
function handleSubmit() {
onSubmit(selected);
}
return ( return (
<Modal <Modal
overflowVisible overflowVisible
@ -42,7 +38,7 @@ function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeIn
submitText='Подтвердить выбор' submitText='Подтвердить выбор'
hideWindow={hideWindow} hideWindow={hideWindow}
canSubmit={isValid} canSubmit={isValid}
onSubmit={handleSubmit} onSubmit={() => onSubmit(selected)}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column')} className={clsx('w-[35rem]', 'pb-3 px-6 cc-column')}
> >
<div className='flex justify-between gap-3 items-center'> <div className='flex justify-between gap-3 items-center'>

View File

@ -34,10 +34,6 @@ function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeL
setBody(newValue.length > 3 ? newValue.substring(3) : ''); setBody(newValue.length > 3 ? newValue.substring(3) : '');
}, []); }, []);
function handleSubmit() {
onChangeLocation(location);
}
return ( return (
<Modal <Modal
overflowVisible overflowVisible
@ -46,7 +42,7 @@ function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeL
submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`} submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`}
hideWindow={hideWindow} hideWindow={hideWindow}
canSubmit={isValid} canSubmit={isValid}
onSubmit={handleSubmit} onSubmit={() => onChangeLocation(location)}
className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3')} className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3')}
> >
<div className='flex flex-col gap-2 w-[7rem] h-min'> <div className='flex flex-col gap-2 w-[7rem] h-min'>

View File

@ -0,0 +1,64 @@
'use client';
import clsx from 'clsx';
import { useState } from 'react';
import Checkbox from '@/components/ui/Checkbox';
import Modal, { ModalProps } from '@/components/ui/Modal';
import TextInput from '@/components/ui/TextInput';
import { IOperation } from '@/models/oss';
interface DlgDeleteOperationProps extends Pick<ModalProps, 'hideWindow'> {
target: IOperation;
onSubmit: (keepConstituents: boolean, deleteSchema: boolean) => void;
}
function DlgDeleteOperation({ hideWindow, target, onSubmit }: DlgDeleteOperationProps) {
const [keepConstituents, setKeepConstituents] = useState(false);
const [deleteSchema, setDeleteSchema] = useState(false);
function handleSubmit() {
onSubmit(keepConstituents, deleteSchema);
}
return (
<Modal
overflowVisible
header='Удаление операции'
submitText='Подтвердить удаление'
hideWindow={hideWindow}
canSubmit={true}
onSubmit={handleSubmit}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column', 'select-none')}
>
<TextInput
disabled
dense
noBorder
id='operation_alias'
label='Операция'
className='w-full'
value={target.alias}
/>
<Checkbox
label='Сохранить наследованные конституенты'
titleHtml='Наследованные конституенты <br/>превратятся в дописанные'
value={keepConstituents}
setValue={setKeepConstituents}
/>
<Checkbox
label='Удалить схему'
titleHtml={
!target.is_owned || target.result === undefined
? 'Привязанную схему нельзя удалить'
: 'Удалить схему вместе с операцией'
}
value={deleteSchema}
setValue={setDeleteSchema}
disabled={!target.is_owned || target.result === undefined}
/>
</Modal>
);
}
export default DlgDeleteOperation;

View File

@ -15,17 +15,12 @@ interface DlgGraphParamsProps extends Pick<ModalProps, 'hideWindow'> {
function DlgGraphParams({ hideWindow, initial, onConfirm }: DlgGraphParamsProps) { function DlgGraphParams({ hideWindow, initial, onConfirm }: DlgGraphParamsProps) {
const [params, updateParams] = usePartialUpdate(initial); const [params, updateParams] = usePartialUpdate(initial);
function handleSubmit() {
hideWindow();
onConfirm(params);
}
return ( return (
<Modal <Modal
canSubmit canSubmit
hideWindow={hideWindow} hideWindow={hideWindow}
header='Настройки графа термов' header='Настройки графа термов'
onSubmit={handleSubmit} onSubmit={() => onConfirm(params)}
submitText='Применить' submitText='Применить'
className='flex gap-6 justify-between px-6 pb-3 w-[30rem]' className='flex gap-6 justify-between px-6 pb-3 w-[30rem]'
> >

View File

@ -26,8 +26,6 @@ function DlgRenameCst({ hideWindow, initial, onRename }: DlgRenameCstProps) {
const [validated, setValidated] = useState(false); const [validated, setValidated] = useState(false);
const [cstData, updateData] = usePartialUpdate(initial); const [cstData, updateData] = usePartialUpdate(initial);
const handleSubmit = () => onRename(cstData);
useLayoutEffect(() => { useLayoutEffect(() => {
if (schema && initial && cstData.cst_type !== initial.cst_type) { if (schema && initial && cstData.cst_type !== initial.cst_type) {
updateData({ alias: generateAlias(cstData.cst_type, schema) }); updateData({ alias: generateAlias(cstData.cst_type, schema) });
@ -47,7 +45,7 @@ function DlgRenameCst({ hideWindow, initial, onRename }: DlgRenameCstProps) {
submitInvalidTooltip={'Введите незанятое имя, соответствующее типу'} submitInvalidTooltip={'Введите незанятое имя, соответствующее типу'}
hideWindow={hideWindow} hideWindow={hideWindow}
canSubmit={validated} canSubmit={validated}
onSubmit={handleSubmit} onSubmit={() => onRename(cstData)}
className={clsx('w-[30rem]', 'py-6 pr-3 pl-6 flex justify-center items-center')} className={clsx('w-[30rem]', 'py-6 pr-3 pl-6 flex justify-center items-center')}
> >
<SelectSingle <SelectSingle

View File

@ -5,10 +5,11 @@ import { useCallback, useEffect, useState } from 'react';
import { getOssDetails } from '@/backend/oss'; import { getOssDetails } from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError'; import { type ErrorData } from '@/components/info/InfoError';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { ILibraryItem } from '@/models/library';
import { IOperationSchema, IOperationSchemaData } from '@/models/oss'; import { IOperationSchema, IOperationSchemaData } from '@/models/oss';
import { OssLoader } from '@/models/OssLoader'; import { OssLoader } from '@/models/OssLoader';
function useOssDetails({ target }: { target?: string }) { function useOssDetails({ target, items }: { target?: string; items: ILibraryItem[] }) {
const { loading: userLoading } = useAuth(); const { loading: userLoading } = useAuth();
const [schema, setInner] = useState<IOperationSchema | undefined>(undefined); const [schema, setInner] = useState<IOperationSchema | undefined>(undefined);
const [loading, setLoading] = useState(target != undefined); const [loading, setLoading] = useState(target != undefined);
@ -19,7 +20,7 @@ function useOssDetails({ target }: { target?: string }) {
setInner(undefined); setInner(undefined);
return; return;
} }
const newSchema = new OssLoader(data).produceOSS(); const newSchema = new OssLoader(data, items).produceOSS();
setInner(newSchema); setInner(newSchema);
} }

View File

@ -3,7 +3,7 @@
*/ */
import { Graph } from './Graph'; import { Graph } from './Graph';
import { LibraryItemID } from './library'; import { ILibraryItem, LibraryItemID } from './library';
import { import {
IOperation, IOperation,
IOperationSchema, IOperationSchema,
@ -21,10 +21,12 @@ export class OssLoader {
private oss: IOperationSchemaData; private oss: IOperationSchemaData;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private operationByID = new Map<OperationID, IOperation>(); private operationByID = new Map<OperationID, IOperation>();
private schemas: LibraryItemID[] = []; private schemaIDs: LibraryItemID[] = [];
private items: ILibraryItem[];
constructor(input: IOperationSchemaData) { constructor(input: IOperationSchemaData, items: ILibraryItem[]) {
this.oss = input; this.oss = input;
this.items = items;
} }
produceOSS(): IOperationSchema { produceOSS(): IOperationSchema {
@ -36,7 +38,7 @@ export class OssLoader {
result.operationByID = this.operationByID; result.operationByID = this.operationByID;
result.graph = this.graph; result.graph = this.graph;
result.schemas = this.schemas; result.schemas = this.schemaIDs;
result.stats = this.calculateStats(); result.stats = this.calculateStats();
return result; return result;
} }
@ -53,12 +55,14 @@ export class OssLoader {
} }
private extractSchemas() { private extractSchemas() {
this.schemas = this.oss.items.map(operation => operation.result).filter(item => item !== null); this.schemaIDs = this.oss.items.map(operation => operation.result).filter(item => item !== null);
} }
private inferOperationAttributes() { private inferOperationAttributes() {
this.graph.topologicalOrder().forEach(operationID => { this.graph.topologicalOrder().forEach(operationID => {
const operation = this.operationByID.get(operationID)!; const operation = this.operationByID.get(operationID)!;
const schema = this.items.find(item => item.id === operation.result);
operation.is_owned = !schema || (schema.owner === this.oss.owner && schema.location === this.oss.location);
operation.substitutions = this.oss.substitutions.filter(item => item.operation === operationID); operation.substitutions = this.oss.substitutions.filter(item => item.operation === operationID);
operation.arguments = this.oss.arguments operation.arguments = this.oss.arguments
.filter(item => item.operation === operationID) .filter(item => item.operation === operationID)
@ -72,7 +76,7 @@ export class OssLoader {
count_operations: items.length, count_operations: items.length,
count_inputs: items.filter(item => item.operation_type === OperationType.INPUT).length, count_inputs: items.filter(item => item.operation_type === OperationType.INPUT).length,
count_synthesis: items.filter(item => item.operation_type === OperationType.SYNTHESIS).length, count_synthesis: items.filter(item => item.operation_type === OperationType.SYNTHESIS).length,
count_schemas: this.schemas.length count_schemas: this.schemaIDs.length
}; };
} }
} }

View File

@ -36,6 +36,7 @@ export interface IOperation {
result: LibraryItemID | null; result: LibraryItemID | null;
is_owned: boolean;
substitutions: ICstSubstituteEx[]; substitutions: ICstSubstituteEx[];
arguments: OperationID[]; arguments: OperationID[];
} }
@ -85,6 +86,14 @@ export interface IOperationUpdateData extends ITargetOperation {
substitutions: ICstSubstitute[] | undefined; substitutions: ICstSubstitute[] | undefined;
} }
/**
* Represents {@link IOperation} data, used in destruction process.
*/
export interface IOperationDeleteData extends ITargetOperation {
keep_constituents: boolean;
delete_schema: boolean;
}
/** /**
* Represents {@link IOperation} data, used in setInput process. * Represents {@link IOperation} data, used in setInput process.
*/ */

View File

@ -33,6 +33,13 @@ function InputNode(node: OssNodeInternal) {
disabled={!hasFile} disabled={!hasFile}
/> />
</Overlay> </Overlay>
{!node.data.operation.is_owned ? (
<Overlay position='left-[0.2rem] top-[0.1rem]'>
<div className='border rounded-none clr-input h-[1.3rem]'></div>
</Overlay>
) : null}
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'> <div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'>
{node.data.label} {node.data.label}
{controller.showTooltip && !node.dragging ? ( {controller.showTooltip && !node.dragging ? (

View File

@ -155,7 +155,7 @@ function NodeContextMenu({
<DropdownButton <DropdownButton
text='Удалить операцию' text='Удалить операцию'
icon={<IconDestroy size='1rem' className='icon-red' />} icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={!controller.isMutable || controller.isProcessing} disabled={!controller.isMutable || controller.isProcessing || !controller.canDelete(operation.id)}
onClick={handleDeleteOperation} onClick={handleDeleteOperation}
/> />
</Dropdown> </Dropdown>

View File

@ -33,6 +33,12 @@ function OperationNode(node: OssNodeInternal) {
/> />
</Overlay> </Overlay>
{!node.data.operation.is_owned ? (
<Overlay position='left-[0.2rem] top-[0.1rem]'>
<div className='border rounded-none clr-input h-[1.3rem]'></div>
</Overlay>
) : null}
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'> <div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'>
{node.data.label} {node.data.label}
{controller.showTooltip && !node.dragging ? ( {controller.showTooltip && !node.dragging ? (

View File

@ -182,12 +182,15 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
if (controller.selected.length !== 1) { if (controller.selected.length !== 1) {
return; return;
} }
controller.deleteOperation(controller.selected[0], getPositions()); handleDeleteOperation(controller.selected[0]);
}, [controller, getPositions]); }, [controller, getPositions]);
const handleDeleteOperation = useCallback( const handleDeleteOperation = useCallback(
(target: OperationID) => { (target: OperationID) => {
controller.deleteOperation(target, getPositions()); if (!controller.canDelete(target)) {
return;
}
controller.promptDeleteOperation(target, getPositions());
}, },
[controller, getPositions] [controller, getPositions]
); );

View File

@ -175,7 +175,11 @@ function ToolbarOssGraph({
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={controller.selected.length !== 1 || controller.isProcessing} disabled={
controller.selected.length !== 1 ||
controller.isProcessing ||
!controller.canDelete(controller.selected[0])
}
onClick={onDelete} onClick={onDelete}
/> />
</div> </div>

View File

@ -13,17 +13,20 @@ import { useOSS } from '@/context/OssContext';
import DlgChangeInputSchema from '@/dialogs/DlgChangeInputSchema'; import DlgChangeInputSchema from '@/dialogs/DlgChangeInputSchema';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation'; import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation'; import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgDeleteOperation from '@/dialogs/DlgDeleteOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors'; import DlgEditEditors from '@/dialogs/DlgEditEditors';
import DlgEditOperation from '@/dialogs/DlgEditOperation'; import DlgEditOperation from '@/dialogs/DlgEditOperation';
import { AccessPolicy, ILibraryItemEditor, LibraryItemID } from '@/models/library'; import { AccessPolicy, ILibraryItemEditor, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous'; import { Position2D } from '@/models/miscellaneous';
import { import {
IOperationCreateData, IOperationCreateData,
IOperationDeleteData,
IOperationPosition, IOperationPosition,
IOperationSchema, IOperationSchema,
IOperationSetInputData, IOperationSetInputData,
IOperationUpdateData, IOperationUpdateData,
OperationID OperationID,
OperationType
} from '@/models/oss'; } from '@/models/oss';
import { UserID, UserLevel } from '@/models/user'; import { UserID, UserLevel } from '@/models/user';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
@ -62,7 +65,8 @@ export interface IOssEditContext extends ILibraryItemEditor {
savePositions: (positions: IOperationPosition[], callback?: () => void) => void; savePositions: (positions: IOperationPosition[], callback?: () => void) => void;
promptCreateOperation: (props: ICreateOperationPrompt) => void; promptCreateOperation: (props: ICreateOperationPrompt) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void; canDelete: (target: OperationID) => boolean;
promptDeleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
createInput: (target: OperationID, positions: IOperationPosition[]) => void; createInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void; promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void; promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void;
@ -103,6 +107,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const [showEditLocation, setShowEditLocation] = useState(false); const [showEditLocation, setShowEditLocation] = useState(false);
const [showEditInput, setShowEditInput] = useState(false); const [showEditInput, setShowEditInput] = useState(false);
const [showEditOperation, setShowEditOperation] = useState(false); const [showEditOperation, setShowEditOperation] = useState(false);
const [showDeleteOperation, setShowDeleteOperation] = useState(false);
const [showCreateOperation, setShowCreateOperation] = useState(false); const [showCreateOperation, setShowCreateOperation] = useState(false);
const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 }); const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 });
@ -258,15 +263,48 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model, positions] [model, positions]
); );
const deleteOperation = useCallback( const canDelete = useCallback(
(target: OperationID, positions: IOperationPosition[]) => { (target: OperationID) => {
model.deleteOperation({ target: target, positions: positions }, () => if (!model.schema) {
toast.success(information.operationDestroyed) return false;
); }
const operation = model.schema.operationByID.get(target);
if (!operation) {
return false;
}
if (operation.operation_type === OperationType.INPUT) {
return true;
}
return model.schema.graph.expandOutputs([target]).length === 0;
}, },
[model] [model]
); );
const promptDeleteOperation = useCallback(
(target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowDeleteOperation(true);
},
[model]
);
const deleteOperation = useCallback(
(keepConstituents: boolean, deleteSchema: boolean) => {
if (!targetOperationID) {
return;
}
const data: IOperationDeleteData = {
target: targetOperationID,
positions: positions,
keep_constituents: keepConstituents,
delete_schema: deleteSchema
};
model.deleteOperation(data, () => toast.success(information.operationDestroyed));
},
[model, targetOperationID, positions]
);
const createInput = useCallback( const createInput = useCallback(
(target: OperationID, positions: IOperationPosition[]) => { (target: OperationID, positions: IOperationPosition[]) => {
model.createInput({ target: target, positions: positions }, new_schema => { model.createInput({ target: target, positions: positions }, new_schema => {
@ -334,7 +372,8 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
openOperationSchema, openOperationSchema,
savePositions, savePositions,
promptCreateOperation, promptCreateOperation,
deleteOperation, canDelete,
promptDeleteOperation,
createInput, createInput,
promptEditInput, promptEditInput,
promptEditOperation, promptEditOperation,
@ -381,6 +420,13 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
onSubmit={handleEditOperation} onSubmit={handleEditOperation}
/> />
) : null} ) : null}
{showDeleteOperation ? (
<DlgDeleteOperation
hideWindow={() => setShowDeleteOperation(false)}
target={targetOperation!}
onSubmit={deleteOperation}
/>
) : null}
</AnimatePresence> </AnimatePresence>
) : null} ) : null}