F: Implement input schema change UI

This commit is contained in:
Ivan 2024-07-28 21:30:10 +03:00
parent 9c5cb64156
commit 591d36d772
16 changed files with 352 additions and 33 deletions

View File

@ -6,6 +6,7 @@ from .data_access import (
OperationCreateSerializer,
OperationSchemaSerializer,
OperationSerializer,
OperationTargetSerializer
OperationTargetSerializer,
SetOperationInputSerializer
)
from .responses import NewOperationResponse, NewSchemaResponse

View File

@ -5,7 +5,7 @@ from django.db.models import F
from rest_framework import serializers
from rest_framework.serializers import PrimaryKeyRelatedField as PKField
from apps.library.models import LibraryItem
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.serializers import LibraryItemDetailsSerializer
from shared import messages as msg
@ -66,9 +66,37 @@ class OperationTargetSerializer(serializers.Serializer):
operation = cast(Operation, attrs['target'])
if oss and operation.oss != oss:
raise serializers.ValidationError({
f'{operation.id}': msg.operationNotOwned(oss.title)
'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())
input = PKField(
many=False,
queryset=LibraryItem.objects.filter(item_type=LibraryItemType.RSFORM),
allow_null=True,
default=None
)
sync_text = serializers.BooleanField(default=False, required=False)
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]
)
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target'])
if oss and operation.oss != oss:
raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title)
})
if operation.operation_type != OperationType.INPUT:
raise serializers.ValidationError({
'target': msg.operationNotInput(operation.alias)
})
self.instance = operation
return attrs

View File

@ -2,7 +2,7 @@
from rest_framework import status
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType, LocationHead
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -208,6 +208,7 @@ class TestOssViewset(EndpointTester):
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation_schema(self):
self.populateData()
Editor.add(self.owned.model, self.user2)
data = {
'item_data': {
'alias': 'Test4',
@ -228,6 +229,7 @@ class TestOssViewset(EndpointTester):
self.assertEqual(schema.visible, False)
self.assertEqual(schema.access_policy, self.owned.model.access_policy)
self.assertEqual(schema.location, self.owned.model.location)
self.assertIn(self.user2, schema.editors())
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self):
@ -287,3 +289,58 @@ class TestOssViewset(EndpointTester):
data['target'] = self.operation3.pk
self.executeBadData(data=data)
@decl_endpoint('/api/oss/{item}/set-input', method='patch')
def test_set_input_null(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'sync_text': True,
'positions': []
}
self.executeBadData(data=data)
data['target'] = self.operation1.pk
data['input'] = None
data['target'] = self.operation1.pk
self.toggle_admin(True)
self.executeBadData(data=data, item=self.unowned_id)
self.logout()
self.executeForbidden(data=data, item=self.owned_id)
self.login()
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.sync_text, True)
self.assertEqual(self.operation1.result, None)
data['input'] = self.ks1.model.pk
self.ks1.model.alias = 'Test42'
self.ks1.model.title = 'Test421'
self.ks1.model.comment = 'TestComment42'
self.ks1.save()
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.sync_text, True)
self.assertEqual(self.operation1.result, self.ks1.model)
self.assertEqual(self.operation1.alias, self.ks1.model.alias)
self.assertEqual(self.operation1.title, self.ks1.model.title)
self.assertEqual(self.operation1.comment, self.ks1.model.comment)
@decl_endpoint('/api/oss/{item}/set-input', method='patch')
def test_set_input_change_schema(self):
self.populateData()
self.operation2.result = None
data = {
'sync_text': True,
'positions': [],
'target': self.operation1.pk,
'input': self.ks2.model.pk
}
response = self.executeOK(data=data, item=self.owned_id)
self.operation2.refresh_from_db()
self.assertEqual(self.operation2.sync_text, True)
self.assertEqual(self.operation2.result, self.ks2.model)

View File

@ -10,7 +10,7 @@ from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.models import Editor, LibraryItem, LibraryItemType
from apps.library.serializers import LibraryItemSerializer
from shared import messages as msg
from shared import permissions
@ -35,7 +35,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_operation',
'delete_operation',
'update_positions',
'create_input'
'create_input',
'set_input'
]:
permission_list = [permissions.ItemEditor]
elif self.action in ['details']:
@ -112,6 +113,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
access_policy=oss.model.access_policy,
location=oss.model.location
)
Editor.set(schema, oss.model.editors())
data['result'] = schema
new_operation = oss.create_operation(**data)
if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data:
@ -201,6 +203,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
access_policy=oss.model.access_policy,
location=oss.model.location
)
Editor.set(schema, oss.model.editors())
operation.result = schema
operation.sync_text = True
operation.save()
@ -213,3 +216,44 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'oss': s.OperationSchemaSerializer(oss.model).data
}
)
@extend_schema(
summary='set input schema for target operation',
tags=['OSS'],
request=s.SetOperationInputSerializer(),
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='set-input')
def set_input(self, request: Request, pk):
''' Set input schema for target operation. '''
serializer = s.SetOperationInputSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
result = serializer.validated_data['input']
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
operation.result = result
operation.sync_text = serializer.validated_data['sync_text']
if result is not None and operation.sync_text:
operation.title = result.title
operation.comment = result.comment
operation.alias = result.alias
operation.save()
# update arguments
oss.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)

View File

@ -212,7 +212,7 @@ class CstTargetSerializer(serializers.Serializer):
cst = cast(Constituenta, attrs['target'])
if schema and cst.schema != schema:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema.title)
f'{cst.id}': msg.constituentaNotInRSform(schema.title)
})
if cst.cst_type not in [CstType.FUNCTION, CstType.STRUCTURED, CstType.TERM]:
raise serializers.ValidationError({
@ -234,7 +234,7 @@ class CstRenameSerializer(serializers.Serializer):
cst = cast(Constituenta, attrs['target'])
if cst.schema != schema:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema.title)
f'{cst.id}': msg.constituentaNotInRSform(schema.title)
})
new_alias = self.initial_data['alias']
if cst.alias == new_alias:
@ -260,7 +260,7 @@ class CstListSerializer(serializers.Serializer):
for item in attrs['items']:
if item.schema != schema:
raise serializers.ValidationError({
f'{item.id}': msg.constituentaNotOwned(schema.title)
f'{item.id}': msg.constituentaNotInRSform(schema.title)
})
return attrs
@ -300,11 +300,11 @@ class CstSubstituteSerializer(serializers.Serializer):
})
if original_cst.schema != schema:
raise serializers.ValidationError({
'original': msg.constituentaNotOwned(schema.title)
'original': msg.constituentaNotInRSform(schema.title)
})
if substitution_cst.schema != schema:
raise serializers.ValidationError({
'substitution': msg.constituentaNotOwned(schema.title)
'substitution': msg.constituentaNotInRSform(schema.title)
})
deleted.add(original_cst.pk)
return attrs
@ -325,14 +325,14 @@ class InlineSynthesisSerializer(serializers.Serializer):
schema_out = cast(LibraryItem, attrs['receiver'])
if user.is_anonymous or (schema_out.owner != user and not user.is_staff):
raise PermissionDenied({
'message': msg.schemaNotOwned(),
'message': msg.schemaForbidden(),
'object_id': schema_in.id
})
constituents = cast(list[Constituenta], attrs['items'])
for cst in constituents:
if cst.schema != schema_in:
raise serializers.ValidationError({
f'{cst.id}': msg.constituentaNotOwned(schema_in.title)
f'{cst.id}': msg.constituentaNotInRSform(schema_in.title)
})
deleted = set()
for item in attrs['substitutions']:
@ -345,7 +345,7 @@ class InlineSynthesisSerializer(serializers.Serializer):
})
if substitution_cst.schema != schema_out:
raise serializers.ValidationError({
f'{substitution_cst.id}': msg.constituentaNotOwned(schema_out.title)
f'{substitution_cst.id}': msg.constituentaNotInRSform(schema_out.title)
})
else:
if substitution_cst not in constituents:
@ -354,7 +354,7 @@ class InlineSynthesisSerializer(serializers.Serializer):
})
if original_cst.schema != schema_out:
raise serializers.ValidationError({
f'{original_cst.id}': msg.constituentaNotOwned(schema_out.title)
f'{original_cst.id}': msg.constituentaNotInRSform(schema_out.title)
})
if original_cst.pk in deleted:
raise serializers.ValidationError({

View File

@ -2,11 +2,11 @@
# pylint: skip-file
def constituentaNotOwned(title: str):
def constituentaNotInRSform(title: str):
return f'Конституента не принадлежит схеме: {title}'
def operationNotOwned(title: str):
def operationNotInOSS(title: str):
return f'Операция не принадлежит схеме: {title}'
@ -14,7 +14,7 @@ def substitutionNotInList():
return 'Отождествляемая конституента отсутствует в списке'
def schemaNotOwned():
def schemaForbidden():
return 'Нет доступа к схеме'

View File

@ -7,6 +7,7 @@ import {
IOperationCreateData,
IOperationCreatedResponse,
IOperationSchemaData,
IOperationSetInputData,
IPositionsData,
ITargetOperation
} from '@/models/oss';
@ -50,3 +51,10 @@ export function patchCreateInput(oss: string, request: FrontExchange<ITargetOper
request: request
});
}
export function patchSetInput(oss: string, request: FrontExchange<IOperationSetInputData, IOperationSchemaData>) {
AxiosPatch({
endpoint: `/api/oss/${oss}/set-input`,
request: request
});
}

View File

@ -12,7 +12,13 @@ import {
patchSetOwner,
postSubscribe
} from '@/backend/library';
import { patchCreateInput, patchDeleteOperation, patchUpdatePositions, postCreateOperation } from '@/backend/oss';
import {
patchCreateInput,
patchDeleteOperation,
patchSetInput,
patchUpdatePositions,
postCreateOperation
} from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError';
import { AccessPolicy, ILibraryItem } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library';
@ -21,6 +27,7 @@ import {
IOperationCreateData,
IOperationSchema,
IOperationSchemaData,
IOperationSetInputData,
IPositionsData,
ITargetOperation
} from '@/models/oss';
@ -55,6 +62,7 @@ interface IOssContext {
createOperation: (data: IOperationCreateData, callback?: DataCallback<IOperation>) => void;
deleteOperation: (data: ITargetOperation, callback?: () => void) => void;
createInput: (data: ITargetOperation, callback?: DataCallback<ILibraryItem>) => void;
setInput: (data: IOperationSetInputData, callback?: () => void) => void;
}
const OssContext = createContext<IOssContext | null>(null);
@ -333,6 +341,27 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
[itemID, library]
);
const setInput = useCallback(
(data: IOperationSetInputData, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
patchSetInput(itemID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: newData => {
library.setGlobalOSS(newData);
library.localUpdateTimestamp(newData.id);
if (callback) callback();
}
});
},
[itemID, schema, library]
);
return (
<OssContext.Provider
value={{
@ -356,7 +385,8 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
savePositions,
createOperation,
deleteOperation,
createInput
createInput,
setInput
}}
>
{children}

View File

@ -157,7 +157,11 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
library.localUpdateItem(newData);
if (library.globalOSS?.schemas.includes(newData.id)) {
library.reloadOSS(() => {
if (callback) callback(newData);
});
} else if (callback) callback(newData);
}
});
},

View File

@ -0,0 +1,79 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { IconReset } from '@/components/Icons';
import PickSchema from '@/components/select/PickSchema';
import Checkbox from '@/components/ui/Checkbox';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal, { ModalProps } from '@/components/ui/Modal';
import { ILibraryItem, LibraryItemID } from '@/models/library';
import { IOperation, IOperationSchema } from '@/models/oss';
interface DlgChangeInputSchemaProps extends Pick<ModalProps, 'hideWindow'> {
oss: IOperationSchema;
target: IOperation;
onSubmit: (newSchema: LibraryItemID | undefined, syncText: boolean) => void;
}
function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeInputSchemaProps) {
const [selected, setSelected] = useState<LibraryItemID | undefined>(target.result ?? undefined);
const [syncText, setSyncText] = useState(target.sync_text);
const baseFilter = useCallback(
(item: ILibraryItem) => !oss.schemas.includes(item.id) || item.id === selected || item.id === target.result,
[oss, selected, target]
);
const isValid = useMemo(() => target.result !== selected, [target, selected]);
const handleSelectLocation = useCallback((newValue: LibraryItemID) => {
setSelected(newValue);
}, []);
function handleSubmit() {
onSubmit(selected, syncText);
}
return (
<Modal
overflowVisible
header='Выбор концептуальной схемы'
submitText='Подтвердить выбор'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column')}
>
<div className='flex justify-between gap-3 items-center'>
<div className='flex gap-3'>
<Label text='Загружаемая концептуальная схема' />
<MiniButton
title='Сбросить выбор схемы'
noHover
noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(undefined)}
disabled={selected == undefined}
/>
</div>
</div>
<PickSchema
value={selected} // prettier: split-line
onSelectValue={handleSelectLocation}
rows={8}
baseFilter={baseFilter}
/>
<Checkbox
value={syncText}
setValue={setSyncText}
label='Синхронизировать текст'
titleHtml='Загрузить текстовые поля<br/> из концептуальной схемы'
/>
</Modal>
);
}
export default DlgChangeInputSchema;

View File

@ -80,7 +80,7 @@ function TabInputOperation({
value={syncText}
setValue={setSyncText}
label='Синхронизировать текст'
title='Брать текст из концептуальной схемы'
titleHtml='Загрузить текстовые поля<br/> из концептуальной схемы'
/>
</FlexColumn>

View File

@ -69,6 +69,14 @@ export interface IOperationCreateData extends IPositionsData {
create_schema: boolean;
}
/**
* Represents {@link IOperation} data, used in setInput process.
*/
export interface IOperationSetInputData extends ITargetOperation {
sync_text: boolean;
input: LibraryItemID | null;
}
/**
* Represents {@link IOperation} Argument.
*/

View File

@ -23,9 +23,18 @@ interface NodeContextMenuProps extends ContextMenuData {
onHide: () => void;
onDelete: (target: OperationID) => void;
onCreateInput: (target: OperationID) => void;
onEditSchema: (target: OperationID) => void;
}
function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCreateInput }: NodeContextMenuProps) {
function NodeContextMenu({
operation,
cursorX,
cursorY,
onHide,
onDelete,
onCreateInput,
onEditSchema
}: NodeContextMenuProps) {
const controller = useOssEdit();
const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);
@ -44,8 +53,8 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
};
const handleEditSchema = () => {
toast.error('Not implemented');
handleHide();
onEditSchema(operation.id);
};
const handleEditOperation = () => {
@ -97,9 +106,9 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
onClick={handleCreateSchema}
/>
) : null}
{controller.isMutable && !operation.result && operation.operation_type === OperationType.INPUT ? (
{controller.isMutable && operation.operation_type === OperationType.INPUT ? (
<DropdownButton
text='Загрузить схему'
text={!operation.result ? 'Загрузить схему' : 'Изменить схему'}
title='Выбрать схему для загрузки'
icon={<IconConnect size='1rem' className='icon-primary' />}
disabled={controller.isProcessing}

View File

@ -28,9 +28,7 @@ function OperationNode(node: OssNodeInternal) {
noHover
title='Связанная КС'
hideTitle={!controller.showTooltip}
onClick={() => {
handleOpenSchema();
}}
onClick={handleOpenSchema}
disabled={!hasFile}
/>
</Overlay>

View File

@ -148,6 +148,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
[controller, getPositions]
);
const handleEditSchema = useCallback(
(target: OperationID) => {
controller.promptEditInput(target, getPositions());
},
[controller, getPositions]
);
const handleFitView = useCallback(() => {
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [flow]);
@ -293,6 +300,7 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
onHide={handleContextMenuHide}
onDelete={handleDeleteOperation}
onCreateInput={handleCreateInput}
onEditSchema={handleEditSchema}
{...menuProps}
/>
) : null}

View File

@ -10,12 +10,19 @@ import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext';
import DlgChangeInputSchema from '@/dialogs/DlgChangeInputSchema';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors';
import { AccessPolicy } from '@/models/library';
import { AccessPolicy, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationPosition, IOperationSchema, OperationID } from '@/models/oss';
import {
IOperationCreateData,
IOperationPosition,
IOperationSchema,
IOperationSetInputData,
OperationID
} from '@/models/oss';
import { UserID, UserLevel } from '@/models/user';
import { information } from '@/utils/labels';
@ -45,6 +52,7 @@ export interface IOssEditContext {
promptCreateOperation: (x: number, y: number, positions: IOperationPosition[]) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
createInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
}
const OssEditContext = createContext<IOssEditContext | null>(null);
@ -79,10 +87,16 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const [showEditEditors, setShowEditEditors] = useState(false);
const [showEditLocation, setShowEditLocation] = useState(false);
const [showEditInput, setShowEditInput] = useState(false);
const [showCreateOperation, setShowCreateOperation] = useState(false);
const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 });
const [positions, setPositions] = useState<IOperationPosition[]>([]);
const [targetOperationID, setTargetOperationID] = useState<OperationID | undefined>(undefined);
const targetOperation = useMemo(
() => (targetOperationID ? model.schema?.operationByID.get(targetOperationID) : undefined),
[model, targetOperationID]
);
useLayoutEffect(
() =>
@ -221,6 +235,28 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model, router]
);
const promptEditInput = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowEditInput(true);
}, []);
const setTargetInput = useCallback(
(newInput: LibraryItemID | undefined, syncText: boolean) => {
if (!targetOperationID) {
return;
}
const data: IOperationSetInputData = {
target: targetOperationID,
positions: positions,
sync_text: syncText,
input: newInput ?? null
};
model.setInput(data, () => toast.success(information.changesSaved));
},
[model, targetOperationID, positions]
);
return (
<OssEditContext.Provider
value={{
@ -246,7 +282,8 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
savePositions,
promptCreateOperation,
deleteOperation,
createInput
createInput,
promptEditInput
}}
>
{model.schema ? (
@ -274,6 +311,14 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
onCreate={handleCreateOperation}
/>
) : null}
{showEditInput ? (
<DlgChangeInputSchema
hideWindow={() => setShowEditInput(false)}
oss={model.schema}
target={targetOperation!}
onSubmit={setTargetInput}
/>
) : null}
</AnimatePresence>
) : null}