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]:
''' Get all Versions of this item. '''
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')
def test_destroy(self):
response = self.execute(item=self.owned.pk)
self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT])
self.executeNoContent(item=self.owned.pk)
self.executeForbidden(item=self.unowned.pk)
self.toggle_admin(True)
response = self.execute(item=self.unowned.pk)
self.assertTrue(response.status_code in [status.HTTP_202_ACCEPTED, status.HTTP_204_NO_CONTENT])
self.executeNoContent(item=self.unowned.pk)
@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.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.serializers import RSFormParseSerializer
from apps.users.models import User
@ -67,6 +67,10 @@ class LibraryViewSet(viewsets.ModelViewSet):
if update_list:
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):
if self.action in ['update', 'partial_update']:
access_level = permissions.ItemEditor

View File

@ -100,7 +100,7 @@ class OperationSchema:
if not keep_constituents:
schema = self.cache.get_schema(target)
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)
target.delete()
self.save(update_fields=['time_update'])
@ -115,7 +115,7 @@ class OperationSchema:
if old_schema is not None:
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)
operation.result = schema
@ -280,7 +280,7 @@ class OperationSchema:
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. '''
self.cache.insert(source)
operation = self.cache.get_operation(source.model.pk)

View File

@ -1,5 +1,5 @@
''' 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 .OperationSchema import CstSubstitution, OperationSchema
@ -35,11 +35,11 @@ class PropagationFacade:
OperationSchema(host).after_update_cst(target, data, old_data, source)
@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. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
OperationSchema(host).before_delete(target, source)
OperationSchema(host).before_delete_cst(target, source)
@staticmethod
def before_substitute(substitutions: CstSubstitution, source: RSForm) -> None:
@ -47,3 +47,15 @@ class PropagationFacade:
hosts = _get_oss_hosts(source.model)
for host in hosts:
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 (
ArgumentSerializer,
OperationCreateSerializer,
OperationDeleteSerializer,
OperationSchemaSerializer,
OperationSerializer,
OperationTargetSerializer,

View File

@ -138,6 +138,26 @@ class OperationTargetSerializer(serializers.Serializer):
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):
''' Serializer: Set input schema for operation. '''
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.ks4D2.definition_formal, r'X1 DEL DEL DEL D1')
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.ks2.model.visible = False
self.ks2.model.save(update_fields=['visible'])
data = {
'positions': [],
'target': self.operation2.pk,
@ -356,7 +358,9 @@ class TestOssViewset(EndpointTester):
}
self.executeOK(data=data, item=self.owned_id)
self.operation2.refresh_from_db()
self.ks2.model.refresh_from_db()
self.assertEqual(self.operation2.result, None)
self.assertEqual(self.ks2.model.visible, True)
data = {
'positions': [],

View File

@ -143,7 +143,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@extend_schema(
summary='delete operation',
tags=['OSS'],
request=s.OperationTargetSerializer,
request=s.OperationDeleteSerializer,
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
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')
def delete_operation(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete operation. '''
serializer = s.OperationTargetSerializer(
serializer = s.OperationDeleteSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
oss = m.OperationSchema(self.get_object())
operation = cast(m.Operation, serializer.validated_data['target'])
old_schema: Optional[LibraryItem] = operation.result
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
# TODO: propagate changes to RSForms
oss.delete_operation(serializer.validated_data['target'])
oss.delete_operation(operation, serializer.validated_data['keep_constituents'])
if old_schema is not None:
if serializer.validated_data['delete_schema']:
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(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
@ -249,9 +254,13 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
raise serializers.ValidationError({
'input': msg.operationInputAlreadyConnected()
})
oss = m.OperationSchema(self.get_object())
old_schema: Optional[LibraryItem] = target_operation.result
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.set_input(target_operation.pk, schema)
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']
schema = m.RSForm(model)
with transaction.atomic():
PropagationFacade.before_delete(cst_list, schema)
PropagationFacade.before_delete_cst(cst_list, schema)
schema.delete_cst(cst_list)
return Response(
status=c.HTTP_200_OK,

View File

@ -6,6 +6,7 @@ import {
IInputCreatedResponse,
IOperationCreateData,
IOperationCreatedResponse,
IOperationDeleteData,
IOperationSchemaData,
IOperationSetInputData,
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({
endpoint: `/api/oss/${oss}/delete-operation`,
request: request

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,10 +31,6 @@ function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeIn
setSelected(newValue);
}, []);
function handleSubmit() {
onSubmit(selected);
}
return (
<Modal
overflowVisible
@ -42,7 +38,7 @@ function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeIn
submitText='Подтвердить выбор'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
onSubmit={() => onSubmit(selected)}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column')}
>
<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) : '');
}, []);
function handleSubmit() {
onChangeLocation(location);
}
return (
<Modal
overflowVisible
@ -46,7 +42,7 @@ function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeL
submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`}
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
onSubmit={() => onChangeLocation(location)}
className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3')}
>
<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) {
const [params, updateParams] = usePartialUpdate(initial);
function handleSubmit() {
hideWindow();
onConfirm(params);
}
return (
<Modal
canSubmit
hideWindow={hideWindow}
header='Настройки графа термов'
onSubmit={handleSubmit}
onSubmit={() => onConfirm(params)}
submitText='Применить'
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 [cstData, updateData] = usePartialUpdate(initial);
const handleSubmit = () => onRename(cstData);
useLayoutEffect(() => {
if (schema && initial && cstData.cst_type !== initial.cst_type) {
updateData({ alias: generateAlias(cstData.cst_type, schema) });
@ -47,7 +45,7 @@ function DlgRenameCst({ hideWindow, initial, onRename }: DlgRenameCstProps) {
submitInvalidTooltip={'Введите незанятое имя, соответствующее типу'}
hideWindow={hideWindow}
canSubmit={validated}
onSubmit={handleSubmit}
onSubmit={() => onRename(cstData)}
className={clsx('w-[30rem]', 'py-6 pr-3 pl-6 flex justify-center items-center')}
>
<SelectSingle

View File

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

View File

@ -3,7 +3,7 @@
*/
import { Graph } from './Graph';
import { LibraryItemID } from './library';
import { ILibraryItem, LibraryItemID } from './library';
import {
IOperation,
IOperationSchema,
@ -21,10 +21,12 @@ export class OssLoader {
private oss: IOperationSchemaData;
private graph: Graph = new Graph();
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.items = items;
}
produceOSS(): IOperationSchema {
@ -36,7 +38,7 @@ export class OssLoader {
result.operationByID = this.operationByID;
result.graph = this.graph;
result.schemas = this.schemas;
result.schemas = this.schemaIDs;
result.stats = this.calculateStats();
return result;
}
@ -53,12 +55,14 @@ export class OssLoader {
}
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() {
this.graph.topologicalOrder().forEach(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.arguments = this.oss.arguments
.filter(item => item.operation === operationID)
@ -72,7 +76,7 @@ export class OssLoader {
count_operations: items.length,
count_inputs: items.filter(item => item.operation_type === OperationType.INPUT).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;
is_owned: boolean;
substitutions: ICstSubstituteEx[];
arguments: OperationID[];
}
@ -85,6 +86,14 @@ export interface IOperationUpdateData extends ITargetOperation {
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.
*/

View File

@ -33,6 +33,13 @@ function InputNode(node: OssNodeInternal) {
disabled={!hasFile}
/>
</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'>
{node.data.label}
{controller.showTooltip && !node.dragging ? (

View File

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

View File

@ -33,6 +33,12 @@ function OperationNode(node: OssNodeInternal) {
/>
</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'>
{node.data.label}
{controller.showTooltip && !node.dragging ? (

View File

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

View File

@ -175,7 +175,11 @@ function ToolbarOssGraph({
<MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
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}
/>
</div>

View File

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