Compare commits

..

7 Commits

Author SHA1 Message Date
Ivan
2a30661355 F: Implementing backend for synthesis operation
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2024-07-31 18:09:08 +03:00
Ivan
6a21125e87 R: Move update-constituenta to RSForm 2024-07-31 14:01:39 +03:00
Ivan
2e19c6fa69 R: Simplify sync_text and transfer_term 2024-07-30 15:59:37 +03:00
Ivan
afd3f5f7e4 F: Prepare frontend for Synthesis execution 2024-07-29 23:20:17 +03:00
Ivan
0a7cfa1375 F: Implement Operation edit 2024-07-29 22:30:24 +03:00
Ivan
d3213211b5 F: Implement UI for synthesis 2024-07-29 16:55:48 +03:00
Ivan
336b61957b F: Implement input schema change UI 2024-07-28 21:29:46 +03:00
67 changed files with 2039 additions and 795 deletions

View File

@ -432,7 +432,8 @@ disable=too-many-public-methods,
missing-function-docstring,
attribute-defined-outside-init,
ungrouped-imports,
abstract-method
abstract-method,
fixme
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option

View File

@ -140,7 +140,7 @@ class LibraryItem(Model):
def _update_connected_operations(self):
# using method level import to prevent circular dependency
from apps.oss.models import Operation # pylint: disable=import-outside-toplevel
operations = Operation.objects.filter(result__pk=self.pk, sync_text=True)
operations = Operation.objects.filter(result__pk=self.pk)
if not operations.exists():
return
for operation in operations:

View File

@ -21,7 +21,7 @@ class ArgumentAdmin(admin.ModelAdmin):
class SynthesisSubstitutionAdmin(admin.ModelAdmin):
''' Admin model: Substitutions as part of Synthesis operation. '''
ordering = ['operation']
list_display = ['id', 'operation', 'original', 'substitution', 'transfer_term']
list_display = ['id', 'operation', 'original', 'substitution']
search_fields = ['id', 'operation', 'original', 'substitution']

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.7 on 2024-07-30 07:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('oss', '0002_inheritance'),
]
operations = [
migrations.RemoveField(
model_name='operation',
name='sync_text',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.7 on 2024-07-30 07:36
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('oss', '0003_remove_operation_sync_text'),
]
operations = [
migrations.RemoveField(
model_name='substitution',
name='transfer_term',
),
]

View File

@ -2,15 +2,18 @@
from django.db.models import (
CASCADE,
SET_NULL,
BooleanField,
CharField,
FloatField,
ForeignKey,
Model,
QuerySet,
TextChoices,
TextField
)
from .Argument import Argument
from .Substitution import Substitution
class OperationType(TextChoices):
''' Type of operation. '''
@ -39,10 +42,6 @@ class Operation(Model):
on_delete=SET_NULL,
related_name='producer'
)
sync_text: BooleanField = BooleanField(
verbose_name='Синхронизация',
default=True
)
alias: CharField = CharField(
verbose_name='Шифр',
@ -74,3 +73,11 @@ class Operation(Model):
def __str__(self) -> str:
return f'Операция {self.alias}'
def getArguments(self) -> QuerySet[Argument]:
''' Operation arguments. '''
return Argument.objects.filter(operation=self)
def getSubstitutions(self) -> QuerySet[Substitution]:
''' Operation substitutions. '''
return Substitution.objects.filter(operation=self)

View File

@ -5,10 +5,12 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.models import Editor, LibraryItem, LibraryItemType
from apps.rsform.models import RSForm
from shared import messages as msg
from .Argument import Argument
from .Inheritance import Inheritance
from .Operation import Operation
from .Substitution import Substitution
@ -76,8 +78,8 @@ class OperationSchema:
''' Delete operation. '''
operation.delete()
# deal with attached schema
# trigger on_change effects
# TODO: deal with attached schema
# TODO: trigger on_change effects
self.save()
@ -86,53 +88,124 @@ class OperationSchema:
''' Set input schema for operation. '''
if schema == target.result:
return
if schema:
target.result = schema
if schema is not None:
target.result = schema
target.alias = schema.alias
target.title = schema.title
target.comment = schema.comment
else:
target.result = None
target.save()
# trigger on_change effects
# TODO: trigger on_change effects
self.save()
@transaction.atomic
def add_argument(self, operation: Operation, argument: Operation) -> Optional[Argument]:
''' Add Argument to operation. '''
if Argument.objects.filter(operation=operation, argument=argument).exists():
return None
result = Argument.objects.create(operation=operation, argument=argument)
self.save()
return result
@transaction.atomic
def clear_arguments(self, target: Operation):
''' Clear all arguments for operation. '''
if not Argument.objects.filter(operation=target).exists():
def set_arguments(self, operation: Operation, arguments: list[Operation]):
''' Set arguments to operation. '''
processed: list[Operation] = []
changed = False
for current in operation.getArguments():
if current.argument not in arguments:
changed = True
current.delete()
else:
processed.append(current.argument)
for arg in arguments:
if arg not in processed:
changed = True
processed.append(arg)
Argument.objects.create(operation=operation, argument=arg)
if not changed:
return
Argument.objects.filter(operation=target).delete()
Substitution.objects.filter(operation=target).delete()
# trigger on_change effects
# TODO: trigger on_change effects
self.save()
@transaction.atomic
def set_substitutions(self, target: Operation, substitutes: list[dict]):
''' Clear all arguments for operation. '''
Substitution.objects.filter(operation=target).delete()
for sub in substitutes:
Substitution.objects.create(
operation=target,
original=sub['original'],
substitution=sub['substitution'],
transfer_term=sub['transfer_term']
)
processed: list[dict] = []
changed = False
# trigger on_change effects
for current in target.getSubstitutions():
subs = [
x for x in substitutes
if x['original'] == current.original and x['substitution'] == current.substitution
]
if len(subs) == 0:
changed = True
current.delete()
else:
processed.append(subs[0])
for sub in substitutes:
if sub not in processed:
changed = True
Substitution.objects.create(
operation=target,
original=sub['original'],
substitution=sub['substitution']
)
if not changed:
return
# TODO: trigger on_change effects
self.save()
@transaction.atomic
def create_input(self, operation: Operation) -> RSForm:
''' Create input RSForm. '''
schema = RSForm.create(
owner=self.model.owner,
alias=operation.alias,
title=operation.title,
comment=operation.comment,
visible=False,
access_policy=self.model.access_policy,
location=self.model.location
)
Editor.set(schema.model, self.model.editors())
operation.result = schema.model
operation.save()
self.save()
return schema
@transaction.atomic
def execute_operation(self, operation: Operation) -> bool:
''' Execute target operation. '''
schemas: list[LibraryItem] = [arg.argument.result for arg in operation.getArguments()]
if None in schemas:
return False
substitutions = operation.getSubstitutions()
receiver = self.create_input(operation)
parents: dict = {}
children: dict = {}
for operand in schemas:
schema = RSForm(operand)
items = list(schema.constituents())
new_items = receiver.insert_copy(items)
for (i, cst) in enumerate(new_items):
parents[cst.pk] = items[i]
children[items[i].pk] = cst
for sub in substitutions:
original = children[sub.original.pk]
replacement = children[sub.substitution.pk]
receiver.substitute(original, replacement)
# TODO: remove duplicates from diamond
for cst in receiver.constituents():
parent = parents.get(cst.id)
assert parent is not None
Inheritance.objects.create(
child=cst,
parent=parent
)
receiver.restore_order()
receiver.reset_aliases()
self.save()
return True

View File

@ -1,5 +1,5 @@
''' Models: Synthesis Substitution. '''
from django.db.models import CASCADE, BooleanField, ForeignKey, Model
from django.db.models import CASCADE, ForeignKey, Model
class Substitution(Model):
@ -22,10 +22,6 @@ class Substitution(Model):
on_delete=CASCADE,
related_name='as_substitute'
)
transfer_term: BooleanField = BooleanField(
verbose_name='Перенос термина',
default=False
)
class Meta:
''' Model metadata. '''

View File

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

View File

@ -21,7 +21,6 @@ class SubstitutionExSerializer(serializers.Serializer):
operation = serializers.IntegerField()
original = serializers.IntegerField()
substitution = serializers.IntegerField()
transfer_term = serializers.BooleanField()
original_alias = serializers.CharField()
original_term = serializers.CharField()
substitution_alias = serializers.CharField()

View File

@ -5,8 +5,10 @@ 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 apps.rsform.models import Constituenta
from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg
from ..models import Argument, Operation, OperationSchema, OperationType
@ -32,7 +34,7 @@ class ArgumentSerializer(serializers.ModelSerializer):
class OperationCreateSerializer(serializers.Serializer):
''' Serializer: Operation creation. '''
class OperationData(serializers.ModelSerializer):
class OperationCreateData(serializers.ModelSerializer):
''' Serializer: Operation creation data. '''
alias = serializers.CharField()
operation_type = serializers.ChoiceField(OperationType.choices)
@ -41,18 +43,83 @@ class OperationCreateSerializer(serializers.Serializer):
''' serializer metadata. '''
model = Operation
fields = \
'alias', 'operation_type', 'title', 'sync_text', \
'alias', 'operation_type', 'title', \
'comment', 'result', 'position_x', 'position_y'
create_schema = serializers.BooleanField(default=False, required=False)
item_data = OperationData()
item_data = OperationCreateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]
)
class OperationUpdateSerializer(serializers.Serializer):
''' Serializer: Operation creation. '''
class OperationUpdateData(serializers.ModelSerializer):
''' Serializer: Operation creation data. '''
class Meta:
''' serializer metadata. '''
model = Operation
fields = 'alias', 'title', 'comment'
target = PKField(many=False, queryset=Operation.objects.all())
item_data = OperationUpdateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
substitutions = serializers.ListField(
child=SubstitutionSerializerBase(),
required=False
)
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]
)
def validate(self, attrs):
if 'arguments' not in attrs:
return attrs
oss = cast(LibraryItem, self.context['oss'])
for operation in attrs['arguments']:
if operation.oss != oss:
raise serializers.ValidationError({
'arguments': msg.operationNotInOSS(oss.title)
})
if 'substitutions' not in attrs:
return attrs
schemas = [arg.result.pk for arg in attrs['arguments'] if arg.result is not None]
substitutions = attrs['substitutions']
to_delete = {x['original'].pk for x in substitutions}
deleted = set()
for item in substitutions:
original_cst = cast(Constituenta, item['original'])
substitution_cst = cast(Constituenta, item['substitution'])
if original_cst.schema.pk not in schemas:
raise serializers.ValidationError({
f'{original_cst.id}': msg.constituentaNotFromOperation()
})
if substitution_cst.schema.pk not in schemas:
raise serializers.ValidationError({
f'{substitution_cst.id}': msg.constituentaNotFromOperation()
})
if original_cst.pk in deleted or substitution_cst.pk in to_delete:
raise serializers.ValidationError({
f'{original_cst.id}': msg.substituteDouble(original_cst.alias)
})
if original_cst.schema == substitution_cst.schema:
raise serializers.ValidationError({
'alias': msg.substituteTrivial(original_cst.alias)
})
deleted.add(original_cst.pk)
return attrs
class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Delete operation. '''
target = PKField(many=False, queryset=Operation.objects.all())
@ -66,9 +133,36 @@ 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
)
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
@ -103,7 +197,6 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
'operation',
'original',
'substitution',
'transfer_term',
original_alias=F('original__alias'),
original_term=F('original__term_resolved'),
substitution_alias=F('substitution__alias'),

View File

@ -29,7 +29,6 @@ class TestOperation(TestCase):
self.assertEqual(self.operation.alias, 'KS1')
self.assertEqual(self.operation.title, '')
self.assertEqual(self.operation.comment, '')
self.assertEqual(self.operation.sync_text, True)
self.assertEqual(self.operation.position_x, 0)
self.assertEqual(self.operation.position_y, 0)
@ -50,15 +49,6 @@ class TestOperation(TestCase):
self.assertEqual(self.operation.title, schema.model.title)
self.assertEqual(self.operation.comment, schema.model.comment)
self.operation.sync_text = False
self.operation.save()
schema.model.alias = 'KS3'
schema.save()
self.operation.refresh_from_db()
self.assertEqual(self.operation.result, schema.model)
self.assertNotEqual(self.operation.alias, schema.model.alias)
def test_sync_from_library_item(self):
schema = LibraryItem.objects.create(alias=self.operation.alias, item_type=LibraryItemType.RSFORM)
self.operation.result = schema

View File

@ -47,8 +47,7 @@ class TestSynthesisSubstitution(TestCase):
self.substitution = Substitution.objects.create(
operation=self.operation3,
original=self.ks1x1,
substitution=self.ks2x1,
transfer_term=False
substitution=self.ks2x1
)

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
@ -23,10 +23,27 @@ class TestOssViewset(EndpointTester):
def populateData(self):
self.ks1 = RSForm.create(alias='KS1', title='Test1', owner=self.user)
self.ks1x1 = self.ks1.insert_new('X1', term_resolved='X1_1')
self.ks2 = RSForm.create(alias='KS2', title='Test2', owner=self.user)
self.ks2x1 = self.ks2.insert_new('X2', term_resolved='X1_2')
self.ks1 = RSForm.create(
alias='KS1',
title='Test1',
owner=self.user
)
self.ks1x1 = self.ks1.insert_new(
'X1',
term_raw='X1_1',
term_resolved='X1_1'
)
self.ks2 = RSForm.create(
alias='KS2',
title='Test2',
owner=self.user
)
self.ks2x1 = self.ks2.insert_new(
'X2',
term_raw='X1_2',
term_resolved='X1_2'
)
self.operation1 = self.owned.create_operation(
alias='1',
operation_type=OperationType.INPUT,
@ -41,12 +58,10 @@ class TestOssViewset(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.owned.add_argument(self.operation3, self.operation1)
self.owned.add_argument(self.operation3, self.operation2)
self.owned.set_arguments(self.operation3, [self.operation1, self.operation2])
self.owned.set_substitutions(self.operation3, [{
'original': self.ks1x1,
'substitution': self.ks2x1,
'transfer_term': False
'substitution': self.ks2x1
}])
@decl_endpoint('/api/oss/{item}/details', method='get')
@ -72,7 +87,6 @@ class TestOssViewset(EndpointTester):
self.assertEqual(sub['operation'], self.operation3.pk)
self.assertEqual(sub['original'], self.ks1x1.pk)
self.assertEqual(sub['substitution'], self.ks2x1.pk)
self.assertEqual(sub['transfer_term'], False)
self.assertEqual(sub['original_alias'], self.ks1x1.alias)
self.assertEqual(sub['original_term'], self.ks1x1.term_resolved)
self.assertEqual(sub['substitution_alias'], self.ks2x1.alias)
@ -135,7 +149,6 @@ class TestOssViewset(EndpointTester):
'alias': 'Test3',
'title': 'Test title',
'comment': 'Тест кириллицы',
'sync_text': False,
'position_x': 1,
'position_y': 1,
},
@ -160,7 +173,6 @@ class TestOssViewset(EndpointTester):
self.assertEqual(new_operation['comment'], data['item_data']['comment'])
self.assertEqual(new_operation['position_x'], data['item_data']['position_x'])
self.assertEqual(new_operation['position_y'], data['item_data']['position_y'])
self.assertEqual(new_operation['sync_text'], data['item_data']['sync_text'])
self.assertEqual(new_operation['result'], None)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.position_x, data['positions'][0]['position_x'])
@ -208,6 +220,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 +241,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):
@ -273,13 +287,11 @@ class TestOssViewset(EndpointTester):
self.operation1.result = None
self.operation1.comment = 'TestComment'
self.operation1.title = 'TestTitle'
self.operation1.sync_text = False
self.operation1.save()
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
new_schema = response.data['new_schema']
self.assertEqual(self.operation1.sync_text, True)
self.assertEqual(new_schema['id'], self.operation1.result.pk)
self.assertEqual(new_schema['alias'], self.operation1.alias)
self.assertEqual(new_schema['title'], self.operation1.title)
@ -287,3 +299,178 @@ 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 = {
'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.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.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 = {
'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.result, self.ks2.model)
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation(self):
self.populateData()
self.executeBadData(item=self.owned_id)
ks3 = RSForm.create(alias='KS3', title='Test3', owner=self.user)
ks3x1 = ks3.insert_new('X1', term_resolved='X1_1')
data = {
'target': self.operation3.pk,
'item_data': {
'alias': 'Test3 mod',
'title': 'Test title mod',
'comment': 'Comment mod'
},
'positions': [],
'arguments': [self.operation1.pk, self.operation2.pk],
'substitutions': [
{
'original': self.ks1x1.pk,
'substitution': ks3x1.pk
}
]
}
self.executeBadData(data=data)
data['substitutions'][0]['substitution'] = self.ks2x1.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.operation3.refresh_from_db()
self.assertEqual(self.operation3.alias, data['item_data']['alias'])
self.assertEqual(self.operation3.title, data['item_data']['title'])
self.assertEqual(self.operation3.comment, data['item_data']['comment'])
self.assertEqual(set([argument.pk for argument in self.operation3.getArguments()]), set(data['arguments']))
sub = self.operation3.getSubstitutions()[0]
self.assertEqual(sub.original.pk, data['substitutions'][0]['original'])
self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution'])
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_sync(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'target': self.operation1.pk,
'item_data': {
'alias': 'Test3 mod',
'title': 'Test title mod',
'comment': 'Comment mod'
},
'positions': [],
}
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.assertEqual(self.operation1.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.title, data['item_data']['title'])
self.assertEqual(self.operation1.comment, data['item_data']['comment'])
self.assertEqual(self.operation1.result.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.result.title, data['item_data']['title'])
self.assertEqual(self.operation1.result.comment, data['item_data']['comment'])
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_invalid_substitution(self):
self.populateData()
self.ks1x2 = self.ks1.insert_new('X2')
data = {
'target': self.operation3.pk,
'item_data': {
'alias': 'Test3 mod',
'title': 'Test title mod',
'comment': 'Comment mod'
},
'positions': [],
'arguments': [self.operation1.pk, self.operation2.pk],
'substitutions': [
{
'original': self.ks1x1.pk,
'substitution': self.ks2x1.pk
},
{
'original': self.ks2x1.pk,
'substitution': self.ks1x2.pk
}
]
}
self.executeBadData(data=data, item=self.owned_id)
@decl_endpoint('/api/oss/{item}/execute-operation', method='post')
def test_execute_operation(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'positions': [],
'target': self.operation1.pk
}
self.executeBadData(data=data)
data['target'] = self.operation3.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()
self.executeOK(data=data)
self.operation3.refresh_from_db()
schema = self.operation3.result
self.assertEqual(schema.alias, self.operation3.alias)
self.assertEqual(schema.comment, self.operation3.comment)
self.assertEqual(schema.title, self.operation3.title)
self.assertEqual(schema.visible, False)
items = list(RSForm(schema).constituents())
self.assertEqual(len(items), 1)
self.assertEqual(items[0].alias, 'X1')
self.assertEqual(items[0].term_resolved, self.ks2x1.term_resolved)

View File

@ -35,7 +35,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_operation',
'delete_operation',
'update_positions',
'create_input'
'create_input',
'set_input',
'update_operation',
'execute_operation'
]:
permission_list = [permissions.ItemEditor]
elif self.action in ['details']:
@ -100,25 +103,14 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
data: dict = serializer.validated_data['item_data']
if data['operation_type'] == m.OperationType.INPUT and serializer.validated_data['create_schema']:
schema = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
owner=oss.model.owner,
alias=data['alias'],
title=data['title'],
comment=data['comment'],
visible=False,
access_policy=oss.model.access_policy,
location=oss.model.location
)
data['result'] = schema
new_operation = oss.create_operation(**data)
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
if new_operation.operation_type == m.OperationType.INPUT and serializer.validated_data['create_schema']:
oss.create_input(new_operation)
if new_operation.operation_type != m.OperationType.INPUT and 'arguments' in serializer.validated_data:
for argument in serializer.validated_data['arguments']:
oss.add_argument(operation=new_operation, argument=argument)
oss.refresh_from_db()
oss.set_arguments(
operation=new_operation,
arguments=serializer.validated_data['arguments']
)
return Response(
status=c.HTTP_201_CREATED,
data={
@ -152,7 +144,6 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss.update_positions(serializer.validated_data['positions'])
oss.delete_operation(serializer.validated_data['target'])
oss.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
@ -191,25 +182,127 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
schema = LibraryItem.objects.create(
item_type=LibraryItemType.RSFORM,
owner=oss.model.owner,
alias=operation.alias,
title=operation.title,
comment=operation.comment,
visible=False,
access_policy=oss.model.access_policy,
location=oss.model.location
)
operation.result = schema
operation.sync_text = True
operation.save()
schema = oss.create_input(operation)
oss.refresh_from_db()
return Response(
status=c.HTTP_200_OK,
data={
'new_schema': LibraryItemSerializer(schema).data,
'new_schema': LibraryItemSerializer(schema.model).data,
'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'])
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
oss.set_input(operation, serializer.validated_data['input'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='update operation',
tags=['OSS'],
request=s.OperationUpdateSerializer(),
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='update-operation')
def update_operation(self, request: Request, pk):
''' Update operation arguments and parameters. '''
serializer = s.OperationUpdateSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
operation.alias = serializer.validated_data['item_data']['alias']
operation.title = serializer.validated_data['item_data']['title']
operation.comment = serializer.validated_data['item_data']['comment']
operation.save()
if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result)
if can_edit:
operation.result.alias = operation.alias
operation.result.title = operation.title
operation.result.comment = operation.comment
operation.result.save()
if 'arguments' in serializer.validated_data:
oss.set_arguments(operation, serializer.validated_data['arguments'])
if 'substitutions' in serializer.validated_data:
oss.set_substitutions(operation, serializer.validated_data['substitutions'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='execute operation',
tags=['OSS'],
request=s.OperationTargetSerializer(),
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=['post'], url_path='execute-operation')
def execute_operation(self, request: Request, pk):
''' Execute operation. '''
serializer = s.OperationTargetSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
if operation.operation_type != m.OperationType.SYNTHESIS:
raise serializers.ValidationError({
'target': msg.operationNotSynthesis(operation.alias)
})
if operation.result is not None:
raise serializers.ValidationError({
'target': msg.operationResultNotEmpty(operation.alias)
})
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_positions(serializer.validated_data['positions'])
oss.execute_operation(operation)
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)

View File

@ -12,7 +12,6 @@ from django.db.models import (
TextChoices,
TextField
)
from django.urls import reverse
from ..utils import apply_pattern
@ -95,10 +94,6 @@ class Constituenta(Model):
verbose_name = 'Конституента'
verbose_name_plural = 'Конституенты'
def get_absolute_url(self):
''' URL access. '''
return reverse('constituenta-detail', kwargs={'pk': self.pk})
def __str__(self) -> str:
return f'{self.alias}'

View File

@ -241,18 +241,12 @@ class RSForm:
def substitute(
self,
original: Constituenta,
substitution: Constituenta,
transfer_term: bool
substitution: Constituenta
):
''' Execute constituenta substitution. '''
assert original.pk != substitution.pk
mapping = {original.alias: substitution.alias}
self.apply_mapping(mapping)
if transfer_term:
substitution.term_raw = original.term_raw
substitution.term_forms = original.term_forms
substitution.term_resolved = original.term_resolved
substitution.save()
original.delete()
self.on_term_change([substitution.id])

View File

@ -19,7 +19,8 @@ from .data_access import (
CstTargetSerializer,
InlineSynthesisSerializer,
RSFormParseSerializer,
RSFormSerializer
RSFormSerializer,
SubstitutionSerializerBase
)
from .io_files import FileSerializer, RSFormTRSSerializer, RSFormUploadSerializer
from .io_pyconcept import PyConceptAdapter

View File

@ -31,7 +31,7 @@ class CstSerializer(serializers.ModelSerializer):
''' serializer metadata. '''
model = Constituenta
fields = '__all__'
read_only_fields = ('id', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved')
read_only_fields = ('id', 'schema', 'order', 'alias', 'cst_type', 'definition_resolved', 'term_resolved')
def update(self, instance: Constituenta, validated_data) -> Constituenta:
data = validated_data # Note: use alias for better code readability
@ -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
@ -270,17 +270,16 @@ class CstMoveSerializer(CstListSerializer):
move_to = serializers.IntegerField()
class CstSubstituteSerializerBase(serializers.Serializer):
class SubstitutionSerializerBase(serializers.Serializer):
''' Serializer: Basic substitution. '''
original = PKField(many=False, queryset=Constituenta.objects.all())
substitution = PKField(many=False, queryset=Constituenta.objects.all())
transfer_term = serializers.BooleanField(required=False, default=False)
class CstSubstituteSerializer(serializers.Serializer):
''' Serializer: Constituenta substitution. '''
substitutions = serializers.ListField(
child=CstSubstituteSerializerBase(),
child=SubstitutionSerializerBase(),
min_length=1
)
@ -300,11 +299,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
@ -316,7 +315,7 @@ class InlineSynthesisSerializer(serializers.Serializer):
source = PKField(many=False, queryset=LibraryItem.objects.all()) # type: ignore
items = PKField(many=True, queryset=Constituenta.objects.all())
substitutions = serializers.ListField(
child=CstSubstituteSerializerBase()
child=SubstitutionSerializerBase()
)
def validate(self, attrs):
@ -325,14 +324,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 +344,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 +353,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

@ -20,12 +20,6 @@ class TestConstituenta(TestCase):
self.assertEqual(str(cst), testStr)
def test_url(self):
testStr = 'X1'
cst = Constituenta.objects.create(alias=testStr, schema=self.schema1.model, order=1, convention='Test')
self.assertEqual(cst.get_absolute_url(), f'/api/constituents/{cst.pk}')
def test_order_not_null(self):
with self.assertRaises(IntegrityError):
Constituenta.objects.create(alias='X1', schema=self.schema1.model)

View File

@ -208,11 +208,11 @@ class TestRSForm(TestCase):
definition_formal=x1.alias
)
self.schema.substitute(x1, x2, True)
self.schema.substitute(x1, x2)
x2.refresh_from_db()
d1.refresh_from_db()
self.assertEqual(self.schema.constituents().count(), 2)
self.assertEqual(x2.term_raw, 'Test')
self.assertEqual(x2.term_raw, 'Test2')
self.assertEqual(d1.definition_formal, x2.alias)

View File

@ -1,6 +1,4 @@
''' Tests for REST API. '''
from .t_cctext import *
from .t_constituents import *
from .t_operations import *
from .t_rsforms import *
from .t_rslang import *

View File

@ -1,102 +0,0 @@
''' Testing API: Constituents. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.rsform_owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.rsform_unowned = RSForm.create(title='Test2', alias='T2')
self.cst1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.rsform_owned.model,
order=1,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.cst2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.rsform_unowned.model,
order=1,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.cst3 = Constituenta.objects.create(
alias='X3',
schema=self.rsform_owned.model,
order=2,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.invalid_cst = self.cst3.pk + 1337
@decl_endpoint('/api/constituents/{item}', method='get')
def test_retrieve(self):
self.executeNotFound(item=self.invalid_cst)
response = self.executeOK(item=self.cst1.pk)
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['convention'], self.cst1.convention)
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_partial_update(self):
data = {'convention': 'tt'}
self.executeForbidden(data=data, item=self.cst2.pk)
self.logout()
self.executeForbidden(data=data, item=self.cst1.pk)
self.login()
response = self.executeOK(data=data, item=self.cst1.pk)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
self.executeOK(data=data, item=self.cst1.pk)
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_update_resolved_no_refs(self):
data = {
'term_raw': 'New term',
'definition_raw': 'New def'
}
response = self.executeOK(data=data, item=self.cst3.pk)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(response.data['definition_resolved'], 'New def')
self.assertEqual(self.cst3.definition_resolved, 'New def')
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_update_resolved_refs(self):
data = {
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
response = self.executeOK(data=data, item=self.cst3.pk)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved)
self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
self.assertEqual(response.data['definition_resolved'], f'{self.cst1.term_resolved} form1')
@decl_endpoint('/api/constituents/{item}', method='patch')
def test_readonly_cst_fields(self):
data = {'alias': 'X33', 'order': 10}
response = self.executeOK(data=data, item=self.cst1.pk)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['order'], self.cst1.order)

View File

@ -1,82 +0,0 @@
''' Testing API: Operations. '''
from apps.rsform.models import Constituenta, CstType, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. '''
@decl_endpoint('/api/operations/inline-synthesis', method='patch')
def setUp(self):
super().setUp()
self.schema1 = RSForm.create(title='Test1', alias='T1', owner=self.user)
self.schema2 = RSForm.create(title='Test2', alias='T2', owner=self.user)
self.unowned = RSForm.create(title='Test3', alias='T3')
def test_inline_synthesis_inputs(self):
invalid_id = 1338
data = {
'receiver': self.unowned.model.pk,
'source': self.schema1.model.pk,
'items': [],
'substitutions': []
}
self.executeForbidden(data=data)
data['receiver'] = invalid_id
self.executeBadData(data=data)
data['receiver'] = self.schema1.model.pk
data['source'] = invalid_id
self.executeBadData(data=data)
data['source'] = self.schema1.model.pk
self.executeOK(data=data)
data['items'] = [invalid_id]
self.executeBadData(data=data)
def test_inline_synthesis(self):
ks1_x1 = self.schema1.insert_new('X1', term_raw='KS1X1') # -> delete
ks1_x2 = self.schema1.insert_new('X2', term_raw='KS1X2') # -> X2
ks1_s1 = self.schema1.insert_new('S1', definition_formal='X2', term_raw='KS1S1') # -> S1
ks1_d1 = self.schema1.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D1
ks2_x1 = self.schema2.insert_new('X1', term_raw='KS2X1') # -> delete
ks2_x2 = self.schema2.insert_new('X2', term_raw='KS2X2') # -> X4
ks2_s1 = self.schema2.insert_new('S1', definition_formal='X2×X2', term_raw='KS2S1') # -> S2
ks2_d1 = self.schema2.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D2
ks2_a1 = self.schema2.insert_new('A1', definition_formal='1=1') # -> not included in items
data = {
'receiver': self.schema1.model.pk,
'source': self.schema2.model.pk,
'items': [ks2_x1.pk, ks2_x2.pk, ks2_s1.pk, ks2_d1.pk],
'substitutions': [
{
'original': ks1_x1.pk,
'substitution': ks2_s1.pk,
'transfer_term': False
},
{
'original': ks2_x1.pk,
'substitution': ks1_s1.pk,
'transfer_term': True
}
]
}
response = self.executeOK(data=data)
result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6)
self.assertEqual(result['X2']['term_raw'], ks1_x2.term_raw)
self.assertEqual(result['X2']['order'], 1)
self.assertEqual(result['X4']['term_raw'], ks2_x2.term_raw)
self.assertEqual(result['X4']['order'], 2)
self.assertEqual(result['S1']['term_raw'], ks2_x1.term_raw)
self.assertEqual(result['S2']['term_raw'], ks2_s1.term_raw)
self.assertEqual(result['S1']['definition_formal'], 'X2')
self.assertEqual(result['S2']['definition_formal'], 'X4×X4')
self.assertEqual(result['D1']['definition_formal'], r'S1\S2\X2')
self.assertEqual(result['D2']['definition_formal'], r'S2\S1\X4')

View File

@ -272,43 +272,6 @@ class TestRSFormViewset(EndpointTester):
self.assertEqual(x1.cst_type, CstType.TERM)
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_single(self):
x1 = self.owned.insert_new(
alias='X1',
term_raw='Test1',
term_resolved='Test1',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}]
)
x2 = self.owned.insert_new(
alias='X2',
term_raw='Test2'
)
unowned = self.unowned.insert_new('X2')
data = {'substitutions': [{'original': x1.pk, 'substitution': unowned.pk, 'transfer_term': True}]}
self.executeForbidden(data=data, item=self.unowned_id)
self.executeBadData(data=data, item=self.owned_id)
data = {'substitutions': [{'original': unowned.pk, 'substitution': x1.pk, 'transfer_term': True}]}
self.executeBadData(data=data, item=self.owned_id)
data = {'substitutions': [{'original': x1.pk, 'substitution': x1.pk, 'transfer_term': True}]}
self.executeBadData(data=data, item=self.owned_id)
d1 = self.owned.insert_new(
alias='D1',
term_raw='@{X2|sing,datv}',
definition_formal='X1'
)
data = {'substitutions': [{'original': x1.pk, 'substitution': x2.pk, 'transfer_term': True}]}
response = self.executeOK(data=data, item=self.owned_id)
d1.refresh_from_db()
x2.refresh_from_db()
self.assertEqual(x2.term_raw, 'Test1')
self.assertEqual(d1.term_resolved, 'form1')
self.assertEqual(d1.definition_formal, 'X2')
@decl_endpoint('/api/rsforms/{item}/substitute', method='patch')
def test_substitute_multiple(self):
self.set_params(item=self.owned_id)
@ -327,13 +290,11 @@ class TestRSFormViewset(EndpointTester):
data = {'substitutions': [
{
'original': x1.pk,
'substitution': d1.pk,
'transfer_term': True
'substitution': d1.pk
},
{
'original': x1.pk,
'substitution': d2.pk,
'transfer_term': True
'substitution': d2.pk
}
]}
self.executeBadData(data=data)
@ -341,13 +302,11 @@ class TestRSFormViewset(EndpointTester):
data = {'substitutions': [
{
'original': x1.pk,
'substitution': d1.pk,
'transfer_term': True
'substitution': d1.pk
},
{
'original': x2.pk,
'substitution': d2.pk,
'transfer_term': True
'substitution': d2.pk
}
]}
response = self.executeOK(data=data, item=self.owned_id)
@ -523,3 +482,172 @@ class TestRSFormViewset(EndpointTester):
self.assertEqual(len(items), 2)
self.assertEqual(items[0]['order'], f1.order + 1)
self.assertEqual(items[0]['definition_formal'], '[α∈X1, β∈X1] Pr1(F10[α,β])')
class TestConstituentaAPI(EndpointTester):
''' Testing Constituenta view. '''
def setUp(self):
super().setUp()
self.rsform_owned = RSForm.create(title='Test', alias='T1', owner=self.user)
self.rsform_unowned = RSForm.create(title='Test2', alias='T2')
self.cst1 = Constituenta.objects.create(
alias='X1',
cst_type=CstType.BASE,
schema=self.rsform_owned.model,
order=1,
convention='Test',
term_raw='Test1',
term_resolved='Test1R',
term_forms=[{'text': 'form1', 'tags': 'sing,datv'}])
self.cst2 = Constituenta.objects.create(
alias='X2',
cst_type=CstType.BASE,
schema=self.rsform_unowned.model,
order=1,
convention='Test1',
term_raw='Test2',
term_resolved='Test2R'
)
self.cst3 = Constituenta.objects.create(
alias='X3',
schema=self.rsform_owned.model,
order=2,
term_raw='Test3',
term_resolved='Test3',
definition_raw='Test1',
definition_resolved='Test2'
)
self.invalid_cst = self.cst3.pk + 1337
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_partial_update(self):
data = {'id': self.cst1.pk, 'convention': 'tt'}
self.executeForbidden(data=data, schema=self.rsform_unowned.model.pk)
self.logout()
self.executeForbidden(data=data, schema=self.rsform_owned.model.pk)
self.login()
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst1.refresh_from_db()
self.assertEqual(response.data['convention'], 'tt')
self.assertEqual(self.cst1.convention, 'tt')
self.executeOK(data=data, schema=self.rsform_owned.model.pk)
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_resolved_no_refs(self):
data = {
'id': self.cst3.pk,
'term_raw': 'New term',
'definition_raw': 'New def'
}
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst3.refresh_from_db()
self.assertEqual(response.data['term_resolved'], 'New term')
self.assertEqual(self.cst3.term_resolved, 'New term')
self.assertEqual(response.data['definition_resolved'], 'New def')
self.assertEqual(self.cst3.definition_resolved, 'New def')
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_update_resolved_refs(self):
data = {
'id': self.cst3.pk,
'term_raw': '@{X1|nomn,sing}',
'definition_raw': '@{X1|nomn,sing} @{X1|sing,datv}'
}
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.cst3.refresh_from_db()
self.assertEqual(self.cst3.term_resolved, self.cst1.term_resolved)
self.assertEqual(response.data['term_resolved'], self.cst1.term_resolved)
self.assertEqual(self.cst3.definition_resolved, f'{self.cst1.term_resolved} form1')
self.assertEqual(response.data['definition_resolved'], f'{self.cst1.term_resolved} form1')
@decl_endpoint('/api/rsforms/{schema}/update-cst', method='patch')
def test_readonly_cst_fields(self):
data = {
'id': self.cst1.pk,
'alias': 'X33',
'order': 10
}
response = self.executeOK(data=data, schema=self.rsform_owned.model.pk)
self.assertEqual(response.data['alias'], 'X1')
self.assertEqual(response.data['alias'], self.cst1.alias)
self.assertEqual(response.data['order'], self.cst1.order)
class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. '''
@decl_endpoint('/api/rsforms/inline-synthesis', method='patch')
def setUp(self):
super().setUp()
self.schema1 = RSForm.create(title='Test1', alias='T1', owner=self.user)
self.schema2 = RSForm.create(title='Test2', alias='T2', owner=self.user)
self.unowned = RSForm.create(title='Test3', alias='T3')
def test_inline_synthesis_inputs(self):
invalid_id = 1338
data = {
'receiver': self.unowned.model.pk,
'source': self.schema1.model.pk,
'items': [],
'substitutions': []
}
self.executeForbidden(data=data)
data['receiver'] = invalid_id
self.executeBadData(data=data)
data['receiver'] = self.schema1.model.pk
data['source'] = invalid_id
self.executeBadData(data=data)
data['source'] = self.schema1.model.pk
self.executeOK(data=data)
data['items'] = [invalid_id]
self.executeBadData(data=data)
def test_inline_synthesis(self):
ks1_x1 = self.schema1.insert_new('X1', term_raw='KS1X1') # -> delete
ks1_x2 = self.schema1.insert_new('X2', term_raw='KS1X2') # -> X2
ks1_s1 = self.schema1.insert_new('S1', definition_formal='X2', term_raw='KS1S1') # -> S1
ks1_d1 = self.schema1.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D1
ks2_x1 = self.schema2.insert_new('X1', term_raw='KS2X1') # -> delete
ks2_x2 = self.schema2.insert_new('X2', term_raw='KS2X2') # -> X4
ks2_s1 = self.schema2.insert_new('S1', definition_formal='X2×X2', term_raw='KS2S1') # -> S2
ks2_d1 = self.schema2.insert_new('D1', definition_formal=r'S1\X1\X2') # -> D2
ks2_a1 = self.schema2.insert_new('A1', definition_formal='1=1') # -> not included in items
data = {
'receiver': self.schema1.model.pk,
'source': self.schema2.model.pk,
'items': [ks2_x1.pk, ks2_x2.pk, ks2_s1.pk, ks2_d1.pk],
'substitutions': [
{
'original': ks1_x1.pk,
'substitution': ks2_s1.pk
},
{
'original': ks2_x1.pk,
'substitution': ks1_s1.pk
}
]
}
response = self.executeOK(data=data)
result = {item['alias']: item for item in response.data['items']}
self.assertEqual(len(result), 6)
self.assertEqual(result['X2']['order'], 1)
self.assertEqual(result['X4']['order'], 2)
self.assertEqual(result['S1']['definition_formal'], 'X2')
self.assertEqual(result['S2']['definition_formal'], 'X4×X4')
self.assertEqual(result['D1']['definition_formal'], r'S1\S2\X2')
self.assertEqual(result['D2']['definition_formal'], r'S2\S1\X4')

View File

@ -9,11 +9,9 @@ library_router.register('rsforms', views.RSFormViewSet, 'RSForm')
urlpatterns = [
path('constituents/<int:pk>', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
path('rsforms/import-trs', views.TrsImportView.as_view()),
path('rsforms/create-detailed', views.create_rsform),
path('operations/inline-synthesis', views.inline_synthesis),
path('rsforms/inline-synthesis', views.inline_synthesis),
path('rslang/parse-expression', views.parse_expression),
path('rslang/to-ascii', views.convert_to_ascii),

View File

@ -1,6 +1,4 @@
''' REST API: Endpoint processors. '''
from .cctext import generate_lexeme, inflect, parse_text
from .constituents import ConstituentAPIView
from .operations import inline_synthesis
from .rsforms import RSFormViewSet, TrsImportView, create_rsform
from .rsforms import RSFormViewSet, TrsImportView, create_rsform, inline_synthesis
from .rslang import convert_to_ascii, convert_to_math, parse_expression

View File

@ -1,16 +0,0 @@
''' Endpoints for Constituenta. '''
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import generics
from shared import permissions
from .. import models as m
from .. import serializers as s
@extend_schema(tags=['Constituenta'])
@extend_schema_view()
class ConstituentAPIView(generics.RetrieveUpdateAPIView, permissions.EditorMixin):
''' Endpoint: Get / Update Constituenta. '''
queryset = m.Constituenta.objects.all()
serializer_class = s.CstSerializer

View File

@ -1,50 +0,0 @@
''' Endpoints for RSForm. '''
from typing import cast
from django.db import transaction
from drf_spectacular.utils import extend_schema
from rest_framework import status as c
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response
from .. import models as m
from .. import serializers as s
@extend_schema(
summary='Inline synthesis: merge one schema into another',
tags=['Operations'],
request=s.InlineSynthesisSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
)
@api_view(['PATCH'])
def inline_synthesis(request: Request):
''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer(
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True)
receiver = m.RSForm(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
with transaction.atomic():
new_items = receiver.insert_copy(items)
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
if original in items:
index = next(i for (i, cst) in enumerate(items) if cst == original)
original = new_items[index]
else:
index = next(i for (i, cst) in enumerate(items) if cst == replacement)
replacement = new_items[index]
receiver.substitute(original, replacement, substitution['transfer_term'])
receiver.restore_order()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(receiver.model).data
)

View File

@ -12,6 +12,7 @@ from rest_framework import views, viewsets
from rest_framework.decorators import action, api_view
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from apps.library.models import AccessPolicy, LibraryItem, LibraryItemType, LocationHead
from apps.library.serializers import LibraryItemSerializer
@ -45,7 +46,8 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'substitute',
'restore_order',
'reset_aliases',
'produce_structure'
'produce_structure',
'update_cst'
]:
permission_list = [permissions.ItemEditor]
elif self.action in [
@ -88,15 +90,43 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
new_cst = m.RSForm(schema).create_cst(data, insert_after)
schema.refresh_from_db()
response = Response(
return Response(
status=c.HTTP_201_CREATED,
data={
'new_cst': s.CstSerializer(new_cst).data,
'schema': s.RSFormParseSerializer(schema).data
}
)
response['Location'] = new_cst.get_absolute_url()
return response
@extend_schema(
summary='update persistent attributes of a given constituenta',
tags=['RSForm'],
request=s.CstSerializer,
responses={
c.HTTP_200_OK: s.CstSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='update-cst')
def update_cst(self, request: Request, pk):
''' Update persistent attributes of a given constituenta. '''
schema = self._get_item()
serializer = s.CstSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
cst = m.Constituenta.objects.get(pk=request.data['id'])
if cst.schema != schema:
raise ValidationError({
'schema': msg.constituentaNotInRSform(schema.title)
})
serializer.update(instance=cst, validated_data=serializer.validated_data)
return Response(
status=c.HTTP_200_OK,
data=s.CstSerializer(cst).data
)
@extend_schema(
summary='produce the structure of a given constituenta',
@ -174,7 +204,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@extend_schema(
summary='substitute constituenta',
summary='execute substitutions',
tags=['RSForm'],
request=s.CstSubstituteSerializer,
responses={
@ -198,7 +228,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
m.RSForm(schema).substitute(original, replacement, substitution['transfer_term'])
m.RSForm(schema).substitute(original, replacement)
schema.refresh_from_db()
return Response(
@ -521,3 +551,41 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[User, None])
data['access_policy'] = request.data.get('access_policy', AccessPolicy.PUBLIC)
data['location'] = request.data.get('location', LocationHead.USER)
@extend_schema(
summary='Inline synthesis: merge one schema into another',
tags=['Operations'],
request=s.InlineSynthesisSerializer,
responses={c.HTTP_200_OK: s.RSFormParseSerializer}
)
@api_view(['PATCH'])
def inline_synthesis(request: Request):
''' Endpoint: Inline synthesis. '''
serializer = s.InlineSynthesisSerializer(
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True)
receiver = m.RSForm(serializer.validated_data['receiver'])
items = cast(list[m.Constituenta], serializer.validated_data['items'])
with transaction.atomic():
new_items = receiver.insert_copy(items)
for substitution in serializer.validated_data['substitutions']:
original = cast(m.Constituenta, substitution['original'])
replacement = cast(m.Constituenta, substitution['substitution'])
if original in items:
index = next(i for (i, cst) in enumerate(items) if cst == original)
original = new_items[index]
else:
index = next(i for (i, cst) in enumerate(items) if cst == replacement)
replacement = new_items[index]
receiver.substitute(original, replacement)
receiver.restore_order()
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(receiver.model).data
)

View File

@ -2,19 +2,27 @@
# pylint: skip-file
def constituentaNotOwned(title: str):
def constituentaNotInRSform(title: str):
return f'Конституента не принадлежит схеме: {title}'
def operationNotOwned(title: str):
return f'Операция не принадлежит схеме: {title}'
def constituentaNotFromOperation():
return f'Конституента не соответствую аргументам операции'
def operationNotInOSS(title: str):
return f'Операция не принадлежит ОСС: {title}'
def previousResultMissing():
return 'Отсутствует результат предыдущей операции'
def substitutionNotInList():
return 'Отождествляемая конституента отсутствует в списке'
def schemaNotOwned():
def schemaForbidden():
return 'Нет доступа к схеме'
@ -22,6 +30,10 @@ def operationNotInput(title: str):
return f'Операция не является Загрузкой: {title}'
def operationNotSynthesis(title: str):
return f'Операция не является Синтезом: {title}'
def operationResultNotEmpty(title: str):
return f'Результат операции не пуст: {title}'

View File

@ -32,6 +32,24 @@ def _extract_item(obj: Any) -> LibraryItem:
})
def can_edit_item(user, obj: Any) -> bool:
if user.is_anonymous:
return False
if hasattr(user, 'is_staff') and user.is_staff:
return True
item = _extract_item(obj)
if item.owner == user:
return True
if Editor.objects.filter(
item=item,
editor=cast(User, user)
).exists() and item.access_policy != AccessPolicy.PRIVATE:
return True
return False
class GlobalAdmin(_Base):
''' Item permission: Admin or higher. '''

View File

@ -1,14 +0,0 @@
/**
* Endpoints: constituents.
*/
import { IConstituentaMeta, ICstUpdateData } from '@/models/rsform';
import { AxiosPatch, FrontExchange } from './apiTransport';
export function patchConstituenta(target: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) {
AxiosPatch({
endpoint: `/api/constituents/${target}`,
request: request
});
}

View File

@ -1,14 +0,0 @@
/**
* Endpoints: operations.
*/
import { IInlineSynthesisData, IRSFormData } from '@/models/rsform';
import { AxiosPatch, FrontExchange } from './apiTransport';
export function patchInlineSynthesis(request: FrontExchange<IInlineSynthesisData, IRSFormData>) {
AxiosPatch({
endpoint: `/api/operations/inline-synthesis`,
request: request
});
}

View File

@ -7,6 +7,8 @@ import {
IOperationCreateData,
IOperationCreatedResponse,
IOperationSchemaData,
IOperationSetInputData,
IOperationUpdateData,
IPositionsData,
ITargetOperation
} from '@/models/oss';
@ -50,3 +52,24 @@ 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
});
}
export function patchUpdateOperation(oss: string, request: FrontExchange<IOperationUpdateData, IOperationSchemaData>) {
AxiosPatch({
endpoint: `/api/oss/${oss}/update-operation`,
request: request
});
}
export function postExecuteOperation(oss: string, request: FrontExchange<ITargetOperation, IOperationSchemaData>) {
AxiosPost({
endpoint: `/api/oss/${oss}/execute-operation`,
request: request
});
}

View File

@ -6,10 +6,13 @@ import { ILibraryCreateData, ILibraryItem } from '@/models/library';
import { ICstSubstituteData } from '@/models/oss';
import {
IConstituentaList,
IConstituentaMeta,
ICstCreateData,
ICstCreatedResponse,
ICstMovetoData,
ICstRenameData,
ICstUpdateData,
IInlineSynthesisData,
IProduceStructureResponse,
IRSFormData,
IRSFormUploadData,
@ -68,6 +71,13 @@ export function postCreateConstituenta(schema: string, request: FrontExchange<IC
});
}
export function patchUpdateConstituenta(schema: string, request: FrontExchange<ICstUpdateData, IConstituentaMeta>) {
AxiosPatch({
endpoint: `/api/rsforms/${schema}/update-cst`,
request: request
});
}
export function patchDeleteConstituenta(schema: string, request: FrontExchange<IConstituentaList, IRSFormData>) {
AxiosPatch({
endpoint: `/api/rsforms/${schema}/delete-multiple-cst`,
@ -135,3 +145,10 @@ export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUpl
}
});
}
export function patchInlineSynthesis(request: FrontExchange<IInlineSynthesisData, IRSFormData>) {
AxiosPatch({
endpoint: `/api/rsforms/inline-synthesis`,
request: request
});
}

View File

@ -89,8 +89,6 @@ export { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';
export { BiPauseCircle as IconStatusIncalculable } from 'react-icons/bi';
export { LuPower as IconKeepAliasOn } from 'react-icons/lu';
export { LuPowerOff as IconKeepAliasOff } from 'react-icons/lu';
export { LuFlag as IconKeepTermOn } from 'react-icons/lu';
export { LuFlagOff as IconKeepTermOff } from 'react-icons/lu';
// ===== Domain actions =====
export { BiUpvote as IconMoveUp } from 'react-icons/bi';
@ -108,7 +106,7 @@ export { LuNetwork as IconGenerateStructure } from 'react-icons/lu';
export { LuBookCopy as IconInlineSynthesis } from 'react-icons/lu';
export { LuWand2 as IconGenerateNames } from 'react-icons/lu';
export { GrConnect as IconConnect } from 'react-icons/gr';
export { BsPlay as IconExecute } from 'react-icons/bs';
export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI =======
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';

View File

@ -1,105 +1,126 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import BadgeConstituenta from '@/components/info/BadgeConstituenta';
import SelectConstituenta from '@/components/select/SelectConstituenta';
import DataTable, { createColumnHelper } from '@/components/ui/DataTable';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { IConstituenta, IRSForm, ISingleSubstitution } from '@/models/rsform';
import { describeConstituenta } from '@/utils/labels';
import { ILibraryItem } from '@/models/library';
import { ICstSubstitute, IMultiSubstitution } from '@/models/oss';
import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform';
import { errors } from '@/utils/labels';
import {
IconKeepAliasOff,
IconKeepAliasOn,
IconKeepTermOff,
IconKeepTermOn,
IconPageFirst,
IconPageLast,
IconPageLeft,
IconPageRight,
IconRemove,
IconReplace
} from '../Icons';
import { IconPageLeft, IconPageRight, IconRemove, IconReplace } from '../Icons';
import NoData from '../ui/NoData';
import SelectLibraryItem from './SelectLibraryItem';
interface PickSubstitutionsProps {
substitutions: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
prefixID: string;
rows?: number;
allowSelfSubstitution?: boolean;
schema1?: IRSForm;
schema2?: IRSForm;
filter1?: (cst: IConstituenta) => boolean;
filter2?: (cst: IConstituenta) => boolean;
items: ISingleSubstitution[];
setItems: React.Dispatch<React.SetStateAction<ISingleSubstitution[]>>;
schemas: IRSForm[];
filter?: (cst: IConstituenta) => boolean;
}
function SubstitutionIcon({ item }: { item: ISingleSubstitution }) {
if (item.deleteRight) {
if (item.takeLeftTerm) {
return <IconPageRight size='1.2rem' />;
} else {
return <IconPageLast size='1.2rem' />;
}
} else {
if (item.takeLeftTerm) {
return <IconPageFirst size='1.2rem' />;
} else {
return <IconPageLeft size='1.2rem' />;
}
}
}
const columnHelper = createColumnHelper<ISingleSubstitution>();
const columnHelper = createColumnHelper<IMultiSubstitution>();
function PickSubstitutions({
items,
schema1,
schema2,
filter1,
filter2,
substitutions,
setSubstitutions,
prefixID,
rows,
setItems,
prefixID
schemas,
filter,
allowSelfSubstitution
}: PickSubstitutionsProps) {
const { colors } = useConceptOptions();
const [leftArgument, setLeftArgument] = useState<ILibraryItem | undefined>(
schemas.length === 1 ? schemas[0] : undefined
);
const [rightArgument, setRightArgument] = useState<ILibraryItem | undefined>(
schemas.length === 1 && allowSelfSubstitution ? schemas[0] : undefined
);
const [leftCst, setLeftCst] = useState<IConstituenta | undefined>(undefined);
const [rightCst, setRightCst] = useState<IConstituenta | undefined>(undefined);
const [deleteRight, setDeleteRight] = useState(true);
const [takeLeftTerm, setTakeLeftTerm] = useState(true);
const [deleteRight, setDeleteRight] = useState(true);
const toggleDelete = () => setDeleteRight(prev => !prev);
const toggleTerm = () => setTakeLeftTerm(prev => !prev);
const getSchemaByCst = useCallback(
(id: ConstituentaID): IRSForm | undefined => {
for (const schema of schemas) {
const cst = schema.cstByID.get(id);
if (cst) {
return schema;
}
}
return undefined;
},
[schemas]
);
const getConstituenta = useCallback(
(id: ConstituentaID): IConstituenta | undefined => {
for (const schema of schemas) {
const cst = schema.cstByID.get(id);
if (cst) {
return cst;
}
}
return undefined;
},
[schemas]
);
const substitutionData: IMultiSubstitution[] = useMemo(
() =>
substitutions.map(item => ({
original_source: getSchemaByCst(item.original),
original: getConstituenta(item.original),
substitution: getConstituenta(item.substitution),
substitution_source: getSchemaByCst(item.substitution)
})),
[getConstituenta, getSchemaByCst, substitutions]
);
function addSubstitution() {
if (!leftCst || !rightCst) {
return;
}
const newSubstitution: ISingleSubstitution = {
leftCst: leftCst,
rightCst: rightCst,
deleteRight: deleteRight,
takeLeftTerm: takeLeftTerm
const newSubstitution: ICstSubstitute = {
original: deleteRight ? rightCst.id : leftCst.id,
substitution: deleteRight ? leftCst.id : rightCst.id
};
setItems([
newSubstitution,
...items.filter(
item =>
(!item.deleteRight && item.leftCst.id !== leftCst.id) ||
(item.deleteRight && item.rightCst.id !== rightCst.id)
)
]);
const toDelete = substitutions.map(item => item.original);
const replacements = substitutions.map(item => item.substitution);
console.log(toDelete, replacements);
console.log(newSubstitution);
if (
toDelete.includes(newSubstitution.original) ||
toDelete.includes(newSubstitution.substitution) ||
replacements.includes(newSubstitution.original)
) {
toast.error(errors.reuseOriginal);
return;
}
setSubstitutions(prev => [...prev, newSubstitution]);
setLeftCst(undefined);
setRightCst(undefined);
}
const handleDeleteRow = useCallback(
(row: number) => {
setItems(prev => {
const newItems: ISingleSubstitution[] = [];
setSubstitutions(prev => {
const newItems: ICstSubstitute[] = [];
prev.forEach((item, index) => {
if (index !== row) {
newItems.push(item);
@ -108,54 +129,62 @@ function PickSubstitutions({
return newItems;
});
},
[setItems]
[setSubstitutions]
);
const columns = useMemo(
() => [
columnHelper.accessor(item => describeConstituenta(item.leftCst), {
id: 'left_text',
header: 'Описание',
size: 1000,
cell: props => <div className='text-xs text-ellipsis'>{props.getValue()}</div>
columnHelper.accessor(item => item.substitution_source?.alias ?? 'N/A', {
id: 'left_schema',
header: 'Операция',
size: 100,
cell: props => <div className='min-w-[10.5rem] text-ellipsis text-right'>{props.getValue()}</div>
}),
columnHelper.accessor(item => item.leftCst.alias, {
columnHelper.accessor(item => item.substitution?.alias ?? 'N/A', {
id: 'left_alias',
header: () => <span className='pl-3'>Имя</span>,
size: 65,
cell: props => (
<BadgeConstituenta theme={colors} value={props.row.original.leftCst} prefixID={`${prefixID}_1_`} />
)
cell: props =>
props.row.original.substitution ? (
<BadgeConstituenta theme={colors} value={props.row.original.substitution} prefixID={`${prefixID}_1_`} />
) : (
'N/A'
)
}),
columnHelper.display({
id: 'status',
header: '',
size: 40,
cell: props => <SubstitutionIcon item={props.row.original} />
cell: () => <IconPageRight size='1.2rem' />
}),
columnHelper.accessor(item => item.rightCst.alias, {
columnHelper.accessor(item => item.original?.alias ?? 'N/A', {
id: 'right_alias',
header: () => <span className='pl-3'>Имя</span>,
size: 65,
cell: props => (
<BadgeConstituenta theme={colors} value={props.row.original.rightCst} prefixID={`${prefixID}_2_`} />
)
cell: props =>
props.row.original.original ? (
<BadgeConstituenta theme={colors} value={props.row.original.original} prefixID={`${prefixID}_1_`} />
) : (
'N/A'
)
}),
columnHelper.accessor(item => describeConstituenta(item.rightCst), {
id: 'right_text',
header: 'Описание',
minSize: 1000,
cell: props => <div className='text-xs text-ellipsis text-pretty'>{props.getValue()}</div>
columnHelper.accessor(item => item.original_source?.alias ?? 'N/A', {
id: 'right_schema',
header: 'Операция',
size: 100,
cell: props => <div className='min-w-[8rem] text-ellipsis'>{props.getValue()}</div>
}),
columnHelper.display({
id: 'actions',
cell: props => (
<MiniButton
noHover
title='Удалить'
icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDeleteRow(props.row.index)}
/>
<div className='max-w-fit'>
<MiniButton
noHover
title='Удалить'
icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDeleteRow(props.row.index)}
/>
</div>
)
})
],
@ -165,87 +194,65 @@ function PickSubstitutions({
return (
<div className='flex flex-col w-full'>
<div className='flex items-end gap-3 justify-stretch'>
<div className='flex-grow basis-1/2'>
<div className='flex items-center justify-between'>
<Label text={schema1 !== schema2 ? schema1?.alias ?? 'Схема 1' : ''} />
<div className='cc-icons'>
<MiniButton
title='Сохранить конституенту'
noHover
onClick={toggleDelete}
icon={
deleteRight ? (
<IconKeepAliasOn size='1rem' className='clr-text-green' />
) : (
<IconKeepAliasOff size='1rem' className='clr-text-red' />
)
}
/>
<MiniButton
title='Сохранить термин'
noHover
onClick={toggleTerm}
icon={
takeLeftTerm ? (
<IconKeepTermOn size='1rem' className='clr-text-green' />
) : (
<IconKeepTermOff size='1rem' className='clr-text-red' />
)
}
/>
</div>
<div className='flex-grow flex flex-col basis-1/2'>
<div className='flex flex-col gap-[0.125rem] border-x border-t clr-input'>
<SelectLibraryItem
noBorder
placeholder='Выберите аргумент'
items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== rightArgument?.id)}
value={leftArgument}
onSelectValue={setLeftArgument}
/>
<SelectConstituenta
noBorder
items={(leftArgument as IRSForm)?.items.filter(
cst => !substitutions.find(item => item.original === cst.id) && (!filter || filter(cst))
)}
value={leftCst}
onSelectValue={setLeftCst}
/>
</div>
<SelectConstituenta
items={schema1?.items.filter(cst => !filter1 || filter1(cst))}
value={leftCst}
onSelectValue={setLeftCst}
</div>
<div className='flex flex-col gap-1'>
<MiniButton
title={deleteRight ? 'Заменить правую' : 'Заменить левую'}
onClick={toggleDelete}
icon={
deleteRight ? (
<IconPageRight size='1.5rem' className='clr-text-primary' />
) : (
<IconPageLeft size='1.5rem' className='clr-text-primary' />
)
}
/>
<MiniButton
title='Добавить в таблицу отождествлений'
className='mb-[0.375rem] grow-0'
icon={<IconReplace size='1.5rem' className='icon-primary' />}
disabled={!leftCst || !rightCst || leftCst === rightCst}
onClick={addSubstitution}
/>
</div>
<MiniButton
noHover
title='Добавить в таблицу отождествлений'
className='mb-[0.375rem] grow-0'
icon={<IconReplace size='1.5rem' className='icon-primary' />}
disabled={!leftCst || !rightCst || leftCst === rightCst}
onClick={addSubstitution}
/>
<div className='flex-grow basis-1/2'>
<div className='flex items-center justify-between'>
<Label text={schema1 !== schema2 ? schema2?.alias ?? 'Схема 2' : ''} />
<div className='cc-icons'>
<MiniButton
title='Сохранить конституенту'
noHover
onClick={toggleDelete}
icon={
!deleteRight ? (
<IconKeepAliasOn size='1rem' className='clr-text-green' />
) : (
<IconKeepAliasOff size='1rem' className='clr-text-red' />
)
}
/>
<MiniButton
title='Сохранить термин'
noHover
onClick={toggleTerm}
icon={
!takeLeftTerm ? (
<IconKeepTermOn size='1rem' className='clr-text-green' />
) : (
<IconKeepTermOff size='1rem' className='clr-text-red' />
)
}
/>
</div>
<div className='flex flex-col gap-[0.125rem] border-x border-t clr-input'>
<SelectLibraryItem
noBorder
placeholder='Выберите аргумент'
items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== leftArgument?.id)}
value={rightArgument}
onSelectValue={setRightArgument}
/>
<SelectConstituenta
noBorder
items={(rightArgument as IRSForm)?.items.filter(
cst => !substitutions.find(item => item.original === cst.id) && (!filter || filter(cst))
)}
value={rightCst}
onSelectValue={setRightCst}
/>
</div>
<SelectConstituenta
items={schema2?.items.filter(cst => !filter2 || filter2(cst))}
value={rightCst}
onSelectValue={setRightCst}
/>
</div>
</div>
@ -256,7 +263,7 @@ function PickSubstitutions({
className='w-full text-sm border select-none cc-scroll-y'
rows={rows}
contentHeight='1.3rem'
data={items}
data={substitutionData}
columns={columns}
headPosition='0'
noDataComponent={

View File

@ -49,7 +49,7 @@ function SelectConstituenta({
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : undefined}
value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}

View File

@ -0,0 +1,60 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { ILibraryItem, LibraryItemID } from '@/models/library';
import { matchLibraryItem } from '@/models/libraryAPI';
import { CProps } from '../props';
import SelectSingle from '../ui/SelectSingle';
interface SelectLibraryItemProps extends CProps.Styling {
items?: ILibraryItem[];
value?: ILibraryItem;
onSelectValue: (newValue?: ILibraryItem) => void;
placeholder?: string;
noBorder?: boolean;
}
function SelectLibraryItem({
className,
items,
value,
onSelectValue,
placeholder = 'Выберите схему',
...restProps
}: SelectLibraryItemProps) {
const options = useMemo(() => {
return (
items?.map(cst => ({
value: cst.id,
label: `${cst.alias}: ${cst.title}`
})) ?? []
);
}, [items]);
const filter = useCallback(
(option: { value: LibraryItemID | undefined; label: string }, inputValue: string) => {
const item = items?.find(item => item.id === option.value);
return !item ? false : matchLibraryItem(item, inputValue);
},
[items]
);
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
placeholder={placeholder}
{...restProps}
/>
);
}
export default SelectLibraryItem;

View File

@ -47,7 +47,7 @@ function SelectOperation({
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : undefined}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}

View File

@ -49,7 +49,7 @@ function SelectUser({
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value, label: getUserLabel(value) } : undefined}
value={value ? { value: value, label: getUserLabel(value) } : null}
onChange={data => {
if (data !== null && data.value !== undefined) onSelectValue(data.value);
}}

View File

@ -12,7 +12,15 @@ import {
patchSetOwner,
postSubscribe
} from '@/backend/library';
import { patchCreateInput, patchDeleteOperation, patchUpdatePositions, postCreateOperation } from '@/backend/oss';
import {
patchCreateInput,
patchDeleteOperation,
patchSetInput,
patchUpdateOperation,
patchUpdatePositions,
postCreateOperation,
postExecuteOperation
} from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError';
import { AccessPolicy, ILibraryItem } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library';
@ -21,6 +29,8 @@ import {
IOperationCreateData,
IOperationSchema,
IOperationSchemaData,
IOperationSetInputData,
IOperationUpdateData,
IPositionsData,
ITargetOperation
} from '@/models/oss';
@ -55,6 +65,9 @@ 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;
updateOperation: (data: IOperationUpdateData, callback?: () => void) => void;
executeOperation: (data: ITargetOperation, callback?: () => void) => void;
}
const OssContext = createContext<IOssContext | null>(null);
@ -333,6 +346,71 @@ 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]
);
const updateOperation = useCallback(
(data: IOperationUpdateData, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
patchUpdateOperation(itemID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: newData => {
library.setGlobalOSS(newData);
library.reloadItems(() => {
if (callback) callback();
});
}
});
},
[itemID, schema, library]
);
const executeOperation = useCallback(
(data: ITargetOperation, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
postExecuteOperation(itemID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: newData => {
library.setGlobalOSS(newData);
library.reloadItems(() => {
if (callback) callback();
});
}
});
},
[itemID, schema, library]
);
return (
<OssContext.Provider
value={{
@ -356,7 +434,10 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
savePositions,
createOperation,
deleteOperation,
createInput
createInput,
setInput,
updateOperation,
executeOperation
}}
>
{children}

View File

@ -3,7 +3,6 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import { patchConstituenta } from '@/backend/constituents';
import {
deleteUnsubscribe,
patchLibraryItem,
@ -14,16 +13,17 @@ import {
postCreateVersion,
postSubscribe
} from '@/backend/library';
import { patchInlineSynthesis } from '@/backend/operations';
import {
getTRSFile,
patchDeleteConstituenta,
patchInlineSynthesis,
patchMoveConstituenta,
patchProduceStructure,
patchRenameConstituenta,
patchResetAliases,
patchRestoreOrder,
patchSubstituteConstituents,
patchUpdateConstituenta,
patchUploadTRS,
postCreateConstituenta
} from '@/backend/rsforms';
@ -157,7 +157,11 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
library.localUpdateItem(newData);
if (callback) callback(newData);
if (library.globalOSS?.schemas.includes(newData.id)) {
library.reloadOSS(() => {
if (callback) callback(newData);
});
} else if (callback) callback(newData);
}
});
},
@ -435,7 +439,7 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
const cstUpdate = useCallback(
(data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => {
setProcessingError(undefined);
patchConstituenta(String(data.id), {
patchUpdateConstituenta(itemID, {
data: data,
showError: true,
setLoading: setProcessing,

View File

@ -0,0 +1,71 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { IconReset } from '@/components/Icons';
import PickSchema from '@/components/select/PickSchema';
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) => void;
}
function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeInputSchemaProps) {
const [selected, setSelected] = useState<LibraryItemID | undefined>(target.result ?? undefined);
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);
}
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}
/>
</Modal>
);
}
export default DlgChangeInputSchema;

View File

@ -10,8 +10,8 @@ import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel';
import { useLibrary } from '@/context/LibraryContext';
import { LibraryItemID } from '@/models/library';
import { HelpTopic, Position2D } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationPosition, IOperationSchema, OperationID, OperationType } from '@/models/oss';
import { HelpTopic } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationSchema, OperationID, OperationType } from '@/models/oss';
import { PARAMETER } from '@/utils/constants';
import { describeOperationType, labelOperationType } from '@/utils/labels';
@ -21,8 +21,6 @@ import TabSynthesisOperation from './TabSynthesisOperation';
interface DlgCreateOperationProps {
hideWindow: () => void;
oss: IOperationSchema;
positions: IOperationPosition[];
insertPosition: Position2D;
onCreate: (data: IOperationCreateData) => void;
}
@ -31,7 +29,7 @@ export enum TabID {
SYNTHESIS = 1
}
function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCreate }: DlgCreateOperationProps) {
function DlgCreateOperation({ hideWindow, oss, onCreate }: DlgCreateOperationProps) {
const library = useLibrary();
const [activeTab, setActiveTab] = useState(TabID.INPUT);
@ -40,7 +38,6 @@ function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCrea
const [comment, setComment] = useState('');
const [inputs, setInputs] = useState<OperationID[]>([]);
const [attachedID, setAttachedID] = useState<LibraryItemID | undefined>(undefined);
const [syncText, setSyncText] = useState(true);
const [createSchema, setCreateSchema] = useState(false);
const isValid = useMemo(
@ -62,16 +59,15 @@ function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCrea
const handleSubmit = () => {
const data: IOperationCreateData = {
item_data: {
position_x: insertPosition.x,
position_y: insertPosition.y,
position_x: 0,
position_y: 0,
alias: alias,
title: title,
comment: comment,
sync_text: activeTab === TabID.INPUT ? syncText : true,
operation_type: activeTab === TabID.INPUT ? OperationType.INPUT : OperationType.SYNTHESIS,
result: activeTab === TabID.INPUT ? attachedID ?? null : null
},
positions: positions,
positions: [],
arguments: activeTab === TabID.INPUT ? undefined : inputs.length > 0 ? inputs : undefined,
create_schema: createSchema
};
@ -91,14 +87,12 @@ function DlgCreateOperation({ hideWindow, oss, insertPosition, positions, onCrea
setTitle={setTitle}
attachedID={attachedID}
setAttachedID={setAttachedID}
syncText={syncText}
setSyncText={setSyncText}
createSchema={createSchema}
setCreateSchema={setCreateSchema}
/>
</TabPanel>
),
[alias, comment, title, attachedID, syncText, oss, createSchema]
[alias, comment, title, attachedID, oss, createSchema]
);
const synthesisPanel = useMemo(

View File

@ -5,7 +5,6 @@ import { useCallback, useEffect } from 'react';
import { IconReset } from '@/components/Icons';
import PickSchema from '@/components/select/PickSchema';
import Checkbox from '@/components/ui/Checkbox';
import FlexColumn from '@/components/ui/FlexColumn';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import TextArea from '@/components/ui/TextArea';
@ -25,8 +24,6 @@ interface TabInputOperationProps {
setComment: React.Dispatch<React.SetStateAction<string>>;
attachedID: LibraryItemID | undefined;
setAttachedID: React.Dispatch<React.SetStateAction<LibraryItemID | undefined>>;
syncText: boolean;
setSyncText: React.Dispatch<React.SetStateAction<boolean>>;
createSchema: boolean;
setCreateSchema: React.Dispatch<React.SetStateAction<boolean>>;
}
@ -41,8 +38,6 @@ function TabInputOperation({
setComment,
attachedID,
setAttachedID,
syncText,
setSyncText,
createSchema,
setCreateSchema
}: TabInputOperationProps) {
@ -51,9 +46,8 @@ function TabInputOperation({
useEffect(() => {
if (createSchema) {
setAttachedID(undefined);
setSyncText(true);
}
}, [createSchema, setAttachedID, setSyncText]);
}, [createSchema, setAttachedID]);
return (
<AnimateFade className='cc-column'>
@ -62,27 +56,19 @@ function TabInputOperation({
label='Полное название'
value={title}
onChange={event => setTitle(event.target.value)}
disabled={syncText && attachedID !== undefined}
disabled={attachedID !== undefined}
/>
<div className='flex gap-6'>
<FlexColumn>
<TextInput
id='operation_alias'
label='Сокращение'
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
disabled={syncText && attachedID !== undefined}
/>
<Checkbox
value={syncText}
setValue={setSyncText}
label='Синхронизировать текст'
title='Брать текст из концептуальной схемы'
/>
</FlexColumn>
<TextInput
id='operation_alias'
label='Сокращение'
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
disabled={attachedID !== undefined}
/>
<TextArea
id='operation_comment'
@ -91,7 +77,7 @@ function TabInputOperation({
rows={3}
value={comment}
onChange={event => setComment(event.target.value)}
disabled={syncText && attachedID !== undefined}
disabled={attachedID !== undefined}
/>
</div>

View File

@ -11,9 +11,9 @@ import { prefixes } from '@/utils/constants';
import ListConstituents from './ListConstituents';
interface DlgDeleteCstProps extends Pick<ModalProps, 'hideWindow'> {
schema: IRSForm;
selected: ConstituentaID[];
onDelete: (items: ConstituentaID[]) => void;
schema: IRSForm;
}
function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstProps) {

View File

@ -0,0 +1,164 @@
'use client';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import BadgeHelp from '@/components/info/BadgeHelp';
import Modal from '@/components/ui/Modal';
import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel';
import useRSFormCache from '@/hooks/useRSFormCache';
import { HelpTopic } from '@/models/miscellaneous';
import {
ICstSubstitute,
IOperation,
IOperationSchema,
IOperationUpdateData,
OperationID,
OperationType
} from '@/models/oss';
import { PARAMETER } from '@/utils/constants';
import TabArguments from './TabArguments';
import TabOperation from './TabOperation';
import TabSynthesis from './TabSynthesis';
interface DlgEditOperationProps {
hideWindow: () => void;
oss: IOperationSchema;
target: IOperation;
onSubmit: (data: IOperationUpdateData) => void;
}
export enum TabID {
CARD = 0,
ARGUMENTS = 1,
SUBSTITUTION = 2
}
function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperationProps) {
const [activeTab, setActiveTab] = useState(TabID.CARD);
const [alias, setAlias] = useState(target.alias);
const [title, setTitle] = useState(target.title);
const [comment, setComment] = useState(target.comment);
const [inputs, setInputs] = useState<OperationID[]>(oss.graph.expandInputs([target.id]));
const inputOperations = useMemo(() => inputs.map(id => oss.operationByID.get(id)!), [inputs, oss.operationByID]);
const schemasIDs = useMemo(
() => inputOperations.map(operation => operation.result).filter(id => id !== null),
[inputOperations]
);
const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>(oss.substitutions);
const cache = useRSFormCache();
const schemas = useMemo(
() => schemasIDs.map(id => cache.getSchema(id)).filter(item => item !== undefined),
[schemasIDs, cache]
);
const isValid = useMemo(() => alias !== '', [alias]);
useEffect(() => {
cache.preload(schemasIDs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schemasIDs]);
const handleSubmit = () => {
const data: IOperationUpdateData = {
target: target.id,
item_data: {
alias: alias,
title: title,
comment: comment
},
positions: [],
arguments: target.operation_type !== OperationType.SYNTHESIS ? undefined : inputs,
substitutions: target.operation_type !== OperationType.SYNTHESIS ? undefined : substitutions
};
onSubmit(data);
};
const cardPanel = useMemo(
() => (
<TabPanel>
<TabOperation
alias={alias}
setAlias={setAlias}
comment={comment}
setComment={setComment}
title={title}
setTitle={setTitle}
/>
</TabPanel>
),
[alias, comment, title]
);
const argumentsPanel = useMemo(
() => (
<TabPanel>
<TabArguments
target={target.id} // prettier: split-lines
oss={oss}
inputs={inputs}
setInputs={setInputs}
/>
</TabPanel>
),
[oss, target, inputs]
);
const synthesisPanel = useMemo(
() => (
<TabPanel>
<TabSynthesis
schemas={schemas}
loading={cache.loading}
error={cache.error}
substitutions={substitutions}
setSubstitutions={setSubstitutions}
/>
</TabPanel>
),
[cache.loading, cache.error, substitutions, schemas]
);
return (
<Modal
header='Редактирование операции'
submitText='Сохранить'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className='w-[40rem] px-6 min-h-[35rem]'
>
<Overlay position='top-0 right-0'>
<BadgeHelp topic={HelpTopic.CC_OSS} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} offset={14} />
</Overlay>
<Tabs
selectedTabClassName='clr-selected'
className='flex flex-col'
selectedIndex={activeTab}
onSelect={setActiveTab}
>
<TabList className={clsx('mb-3 self-center', 'flex', 'border divide-x rounded-none')}>
<TabLabel title='Текстовые поля' label='Карточка' className='w-[8rem]' />
{target.operation_type === OperationType.SYNTHESIS ? (
<TabLabel title='Выбор аргументов операции' label='Аргументы' className='w-[8rem]' />
) : null}
{target.operation_type === OperationType.SYNTHESIS ? (
<TabLabel title='Таблица отождествлений' label='Отождествления' className='w-[8rem]' />
) : null}
</TabList>
{cardPanel}
{target.operation_type === OperationType.SYNTHESIS ? argumentsPanel : null}
{target.operation_type === OperationType.SYNTHESIS ? synthesisPanel : null}
</Tabs>
</Modal>
);
}
export default DlgEditOperation;

View File

@ -0,0 +1,35 @@
'use client';
import { useMemo } from 'react';
import FlexColumn from '@/components/ui/FlexColumn';
import Label from '@/components/ui/Label';
import AnimateFade from '@/components/wrap/AnimateFade';
import { IOperationSchema, OperationID } from '@/models/oss';
import PickMultiOperation from '../../components/select/PickMultiOperation';
interface TabArgumentsProps {
oss: IOperationSchema;
target: OperationID;
inputs: OperationID[];
setInputs: React.Dispatch<React.SetStateAction<OperationID[]>>;
}
function TabArguments({ oss, inputs, target, setInputs }: TabArgumentsProps) {
const potentialCycle = useMemo(() => [target, ...oss.graph.expandAllOutputs([target])], [target, oss.graph]);
const filtered = useMemo(
() => oss.items.filter(item => !potentialCycle.includes(item.id)),
[oss.items, potentialCycle]
);
return (
<AnimateFade className='cc-column'>
<FlexColumn>
<Label text={`Выбор аргументов: [ ${inputs.length} ]`} />
<PickMultiOperation items={filtered} selected={inputs} setSelected={setInputs} rows={8} />
</FlexColumn>
</AnimateFade>
);
}
export default TabArguments;

View File

@ -0,0 +1,48 @@
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import AnimateFade from '@/components/wrap/AnimateFade';
import { limits, patterns } from '@/utils/constants';
interface TabOperationProps {
alias: string;
setAlias: React.Dispatch<React.SetStateAction<string>>;
title: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
comment: string;
setComment: React.Dispatch<React.SetStateAction<string>>;
}
function TabOperation({ alias, setAlias, title, setTitle, comment, setComment }: TabOperationProps) {
return (
<AnimateFade className='cc-column'>
<TextInput
id='operation_title'
label='Полное название'
value={title}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex gap-6'>
<TextInput
id='operation_alias'
label='Сокращение'
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<TextArea
id='operation_comment'
label='Описание'
noResize
rows={3}
value={comment}
onChange={event => setComment(event.target.value)}
/>
</div>
</AnimateFade>
);
}
export default TabOperation;

View File

@ -0,0 +1,31 @@
import { ErrorData } from '@/components/info/InfoError';
import PickSubstitutions from '@/components/select/PickSubstitutions';
import DataLoader from '@/components/wrap/DataLoader';
import { ICstSubstitute } from '@/models/oss';
import { IRSForm } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
interface TabSynthesisProps {
loading: boolean;
error: ErrorData;
schemas: IRSForm[];
substitutions: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
}
function TabSynthesis({ schemas, loading, error, substitutions, setSubstitutions }: TabSynthesisProps) {
return (
<DataLoader id='dlg-synthesis-tab' className='cc-column mt-3' isLoading={loading} error={error}>
<PickSubstitutions
schemas={schemas}
prefixID={prefixes.dlg_cst_substitutes_list}
rows={8}
substitutions={substitutions}
setSubstitutions={setSubstitutions}
/>
</DataLoader>
);
}
export default TabSynthesis;

View File

@ -0,0 +1 @@
export { default } from './DlgEditOperation';

View File

@ -8,7 +8,8 @@ import Modal, { ModalProps } from '@/components/ui/Modal';
import TabLabel from '@/components/ui/TabLabel';
import useRSFormDetails from '@/hooks/useRSFormDetails';
import { LibraryItemID } from '@/models/library';
import { IInlineSynthesisData, IRSForm, ISingleSubstitution } from '@/models/rsform';
import { ICstSubstitute } from '@/models/oss';
import { IInlineSynthesisData, IRSForm } from '@/models/rsform';
import TabConstituents from './TabConstituents';
import TabSchema from './TabSchema';
@ -30,7 +31,7 @@ function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInli
const [donorID, setDonorID] = useState<LibraryItemID | undefined>(undefined);
const [selected, setSelected] = useState<LibraryItemID[]>([]);
const [substitutions, setSubstitutions] = useState<ISingleSubstitution[]>([]);
const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>([]);
const source = useRSFormDetails({ target: donorID ? String(donorID) : undefined });
@ -44,11 +45,7 @@ function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInli
source: source.schema?.id,
receiver: receiver.id,
items: selected,
substitutions: substitutions.map(item => ({
original: item.deleteRight ? item.rightCst.id : item.leftCst.id,
substitution: item.deleteRight ? item.leftCst.id : item.rightCst.id,
transfer_term: !item.deleteRight && item.takeLeftTerm
}))
substitutions: substitutions
};
onInlineSynthesis(data);
}

View File

@ -1,11 +1,13 @@
'use client';
import { ErrorData } from '@/components/info/InfoError';
import DataLoader from '@/components/wrap/DataLoader';
import { ConstituentaID, IRSForm, ISingleSubstitution } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
import { useCallback, useMemo } from 'react';
import PickSubstitutions from '../../components/select/PickSubstitutions';
import { ErrorData } from '@/components/info/InfoError';
import PickSubstitutions from '@/components/select/PickSubstitutions';
import DataLoader from '@/components/wrap/DataLoader';
import { ICstSubstitute } from '@/models/oss';
import { ConstituentaID, IConstituenta, IRSForm } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
interface TabSubstitutionsProps {
receiver?: IRSForm;
@ -15,8 +17,8 @@ interface TabSubstitutionsProps {
loading?: boolean;
error?: ErrorData;
substitutions: ISingleSubstitution[];
setSubstitutions: React.Dispatch<React.SetStateAction<ISingleSubstitution[]>>;
substitutions: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
}
function TabSubstitutions({
@ -30,16 +32,22 @@ function TabSubstitutions({
substitutions,
setSubstitutions
}: TabSubstitutionsProps) {
const filter = useCallback(
(cst: IConstituenta) => cst.id !== source?.id || selected.includes(cst.id),
[selected, source]
);
const schemas = useMemo(() => [...(source ? [source] : []), ...(receiver ? [receiver] : [])], [source, receiver]);
return (
<DataLoader id='dlg-substitutions-tab' className='cc-column' isLoading={loading} error={error} hasNoData={!source}>
<PickSubstitutions
items={substitutions}
setItems={setSubstitutions}
substitutions={substitutions}
setSubstitutions={setSubstitutions}
rows={10}
prefixID={prefixes.cst_inline_synth_substitutes}
schema1={receiver}
schema2={source}
filter2={cst => selected.includes(cst.id)}
schemas={schemas}
filter={filter}
/>
</DataLoader>
);

View File

@ -5,29 +5,23 @@ import { useMemo, useState } from 'react';
import PickSubstitutions from '@/components/select/PickSubstitutions';
import Modal, { ModalProps } from '@/components/ui/Modal';
import { useRSForm } from '@/context/RSFormContext';
import { ICstSubstituteData } from '@/models/oss';
import { ISingleSubstitution } from '@/models/rsform';
import { ICstSubstitute, ICstSubstituteData } from '@/models/oss';
import { IRSForm } from '@/models/rsform';
import { prefixes } from '@/utils/constants';
interface DlgSubstituteCstProps extends Pick<ModalProps, 'hideWindow'> {
schema: IRSForm;
onSubstitute: (data: ICstSubstituteData) => void;
}
function DlgSubstituteCst({ hideWindow, onSubstitute }: DlgSubstituteCstProps) {
const { schema } = useRSForm();
const [substitutions, setSubstitutions] = useState<ISingleSubstitution[]>([]);
function DlgSubstituteCst({ hideWindow, onSubstitute, schema }: DlgSubstituteCstProps) {
const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>([]);
const canSubmit = useMemo(() => substitutions.length > 0, [substitutions]);
function handleSubmit() {
const data: ICstSubstituteData = {
substitutions: substitutions.map(item => ({
original: item.deleteRight ? item.rightCst.id : item.leftCst.id,
substitution: item.deleteRight ? item.leftCst.id : item.rightCst.id,
transfer_term: !item.deleteRight && item.takeLeftTerm
}))
substitutions: substitutions
};
onSubstitute(data);
}
@ -43,12 +37,12 @@ function DlgSubstituteCst({ hideWindow, onSubstitute }: DlgSubstituteCstProps) {
className={clsx('w-[40rem]', 'px-6 pb-3')}
>
<PickSubstitutions
items={substitutions}
setItems={setSubstitutions}
allowSelfSubstitution
substitutions={substitutions}
setSubstitutions={setSubstitutions}
rows={6}
prefixID={prefixes.dlg_cst_substitutes_list}
schema1={schema}
schema2={schema}
schemas={[schema]}
/>
</Modal>
);

View File

@ -0,0 +1,82 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getRSFormDetails } from '@/backend/rsforms';
import { type ErrorData } from '@/components/info/InfoError';
import { LibraryItemID } from '@/models/library';
import { ConstituentaID, IRSForm, IRSFormData } from '@/models/rsform';
import { RSFormLoader } from '@/models/RSFormLoader';
function useRSFormCache() {
const [cache, setCache] = useState<IRSForm[]>([]);
const [pending, setPending] = useState<LibraryItemID[]>([]);
const [processing, setProcessing] = useState<LibraryItemID[]>([]);
const loading = useMemo(() => pending.length > 0 || processing.length > 0, [pending, processing]);
const [error, setError] = useState<ErrorData>(undefined);
function setSchema(data: IRSFormData) {
const schema = new RSFormLoader(data).produceRSForm();
setCache(prev => [...prev, schema]);
}
const getSchema = useCallback((id: LibraryItemID) => cache.find(item => item.id === id), [cache]);
const getSchemaByCst = useCallback(
(id: ConstituentaID) => {
for (const schema of cache) {
const cst = schema.items.find(cst => cst.id === id);
if (cst) {
return schema;
}
}
return undefined;
},
[cache]
);
const getConstituenta = useCallback(
(id: ConstituentaID) => {
for (const schema of cache) {
const cst = schema.items.find(cst => cst.id === id);
if (cst) {
return cst;
}
}
return undefined;
},
[cache]
);
const preload = useCallback(
(target: LibraryItemID[]) => setPending(prev => [...prev, ...target.filter(id => !prev.includes(id))]),
[]
);
useEffect(() => {
const ids = pending.filter(id => !processing.includes(id) && !cache.find(schema => schema.id === id));
if (ids.length === 0) {
return;
}
setProcessing(prev => [...prev, ...ids]);
setPending([]);
ids.forEach(id =>
getRSFormDetails(String(id), '', {
showError: false,
onError: error => {
setProcessing(prev => prev.filter(item => item !== id));
setError(error);
},
onSuccess: data => {
setProcessing(prev => prev.filter(item => item !== id));
setSchema(data);
}
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending]);
return { preload, getSchema, getConstituenta, getSchemaByCst, loading, error, setError };
}
export default useRSFormCache;

View File

@ -4,7 +4,7 @@
import { Graph } from './Graph';
import { ILibraryItem, ILibraryItemData, LibraryItemID } from './library';
import { ConstituentaID } from './rsform';
import { ConstituentaID, IConstituenta } from './rsform';
/**
* Represents {@link IOperation} identifier type.
@ -30,7 +30,6 @@ export interface IOperation {
alias: string;
title: string;
comment: string;
sync_text: boolean;
position_x: number;
position_y: number;
@ -63,12 +62,28 @@ export interface ITargetOperation extends IPositionsData {
export interface IOperationCreateData extends IPositionsData {
item_data: Pick<
IOperation,
'alias' | 'operation_type' | 'title' | 'comment' | 'position_x' | 'position_y' | 'result' | 'sync_text'
'alias' | 'operation_type' | 'title' | 'comment' | 'position_x' | 'position_y' | 'result'
>;
arguments: OperationID[] | undefined;
create_schema: boolean;
}
/**
* Represents {@link IOperation} data, used in update process.
*/
export interface IOperationUpdateData extends ITargetOperation {
item_data: Pick<IOperation, 'alias' | 'title' | 'comment'>;
arguments: OperationID[] | undefined;
substitutions: ICstSubstitute[] | undefined;
}
/**
* Represents {@link IOperation} data, used in setInput process.
*/
export interface IOperationSetInputData extends ITargetOperation {
input: LibraryItemID | null;
}
/**
* Represents {@link IOperation} Argument.
*/
@ -83,7 +98,6 @@ export interface IArgument {
export interface ICstSubstitute {
original: ConstituentaID;
substitution: ConstituentaID;
transfer_term: boolean;
}
/**
@ -93,6 +107,16 @@ export interface ICstSubstituteData {
substitutions: ICstSubstitute[];
}
/**
* Represents substitution for multi synthesis table.
*/
export interface IMultiSubstitution {
original_source: ILibraryItem | undefined;
original: IConstituenta | undefined;
substitution: IConstituenta | undefined;
substitution_source: ILibraryItem | undefined;
}
/**
* Represents {@link ICstSubstitute} extended data.
*/

View File

@ -241,13 +241,12 @@ export interface IVersionCreatedResponse {
}
/**
* Represents single substitution for synthesis table.
* Represents single substitution for binary synthesis table.
*/
export interface ISingleSubstitution {
export interface IBinarySubstitution {
leftCst: IConstituenta;
rightCst: IConstituenta;
deleteRight: boolean;
takeLeftTerm: boolean;
}
/**

View File

@ -4,8 +4,6 @@ import {
IconGenerateNames,
IconGenerateStructure,
IconInlineSynthesis,
IconKeepAliasOn,
IconKeepTermOn,
IconReplace,
IconSortList,
IconTemplates
@ -60,13 +58,7 @@ function HelpRSLangOperations() {
</h2>
<p>
Формирование таблицы отождествлений и ее применение к текущей схеме. В результате будет удален ряд конституент и
их вхождения заменены на другие. Возможна настройка какой термин использовать для оставшихся конституент
<li>
<IconKeepAliasOn size='1.25rem' className='inline-icon' /> выбор сохраняемой конституенты
</li>
<li>
<IconKeepTermOn size='1.25rem' className='inline-icon' /> выбор сохраняемого термина
</li>
их вхождения заменены на другие.
</p>
<h2>

View File

@ -1,7 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewItem, IconRSForm } from '@/components/Icons';
import Dropdown from '@/components/ui/Dropdown';
@ -23,12 +22,45 @@ interface NodeContextMenuProps extends ContextMenuData {
onHide: () => void;
onDelete: (target: OperationID) => void;
onCreateInput: (target: OperationID) => void;
onEditSchema: (target: OperationID) => void;
onEditOperation: (target: OperationID) => void;
onExecuteOperation: (target: OperationID) => void;
}
function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCreateInput }: NodeContextMenuProps) {
function NodeContextMenu({
operation,
cursorX,
cursorY,
onHide,
onDelete,
onCreateInput,
onEditSchema,
onEditOperation,
onExecuteOperation
}: NodeContextMenuProps) {
const controller = useOssEdit();
const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);
const readyForSynthesis = useMemo(() => {
if (operation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (!controller.schema || operation.result) {
return false;
}
const argumentIDs = controller.schema.graph.expandInputs([operation.id]);
if (!argumentIDs || argumentIDs.length < 2) {
return false;
}
const argumentOperations = argumentIDs.map(id => controller.schema!.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
}, [operation, controller.schema]);
const handleHide = useCallback(() => {
setIsOpen(false);
@ -44,13 +76,13 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
};
const handleEditSchema = () => {
toast.error('Not implemented');
handleHide();
onEditSchema(operation.id);
};
const handleEditOperation = () => {
toast.error('Not implemented');
handleHide();
onEditOperation(operation.id);
};
const handleDeleteOperation = () => {
@ -64,8 +96,8 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
};
const handleRunSynthesis = () => {
toast.error('Not implemented');
handleHide();
onExecuteOperation(operation.id);
};
return (
@ -97,9 +129,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}
@ -109,9 +141,13 @@ function NodeContextMenu({ operation, cursorX, cursorY, onHide, onDelete, onCrea
{controller.isMutable && !operation.result && operation.operation_type === OperationType.SYNTHESIS ? (
<DropdownButton
text='Выполнить синтез'
title='Выполнить операцию и получить синтезированную КС'
title={
readyForSynthesis
? 'Выполнить операцию и получить синтезированную КС'
: 'Необходимо предоставить все аргументы'
}
icon={<IconExecute size='1rem' className='icon-green' />}
disabled={controller.isProcessing}
disabled={controller.isProcessing || !readyForSynthesis}
onClick={handleRunSynthesis}
/>
) : null}

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,34 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
[controller, getPositions]
);
const handleEditSchema = useCallback(
(target: OperationID) => {
controller.promptEditInput(target, getPositions());
},
[controller, getPositions]
);
const handleEditOperation = useCallback(
(target: OperationID) => {
controller.promptEditOperation(target, getPositions());
},
[controller, getPositions]
);
const handleExecuteOperation = useCallback(
(target: OperationID) => {
controller.executeOperation(target, getPositions());
},
[controller, getPositions]
);
const handleExecuteSelected = useCallback(() => {
if (controller.selected.length !== 1) {
return;
}
handleExecuteOperation(controller.selected[0]);
}, [controller, handleExecuteOperation]);
const handleFitView = useCallback(() => {
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [flow]);
@ -213,6 +241,17 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
handleContextMenuHide();
}, [handleContextMenuHide]);
const handleNodeClick = useCallback(
(event: CProps.EventMouse, node: OssNode) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
event.stopPropagation();
handleEditOperation(Number(node.id));
}
},
[handleEditOperation]
);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (controller.isProcessing) {
return;
@ -226,6 +265,12 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
handleSavePositions();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key === 'q') {
event.preventDefault();
event.stopPropagation();
handleCreateOperation();
return;
}
if (event.key === 'Delete') {
event.preventDefault();
event.stopPropagation();
@ -252,8 +297,9 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
fitView
onNodeClick={handleNodeClick}
proOptions={{ hideAttribution: true }}
fitView
nodeTypes={OssNodeTypes}
maxZoom={2}
minZoom={0.75}
@ -266,7 +312,17 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
{showGrid ? <Background gap={10} /> : null}
</ReactFlow>
),
[nodes, edges, handleNodesChange, handleContextMenu, handleClickCanvas, onEdgesChange, OssNodeTypes, showGrid]
[
nodes,
edges,
handleNodesChange,
handleContextMenu,
handleClickCanvas,
onEdgesChange,
handleNodeClick,
OssNodeTypes,
showGrid
]
);
return (
@ -280,6 +336,8 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
onFitView={handleFitView}
onCreate={handleCreateOperation}
onDelete={handleDeleteSelected}
onEdit={() => handleEditOperation(controller.selected[0])}
onExecute={handleExecuteSelected}
onResetPositions={handleResetPositions}
onSavePositions={handleSavePositions}
onSaveImage={handleSaveImage}
@ -293,6 +351,9 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
onHide={handleContextMenuHide}
onDelete={handleDeleteOperation}
onCreateInput={handleCreateInput}
onEditSchema={handleEditSchema}
onEditOperation={handleEditOperation}
onExecuteOperation={handleExecuteOperation}
{...menuProps}
/>
) : null}

View File

@ -1,9 +1,14 @@
'use client';
import clsx from 'clsx';
import { useMemo } from 'react';
import {
IconAnimation,
IconAnimationOff,
IconDestroy,
IconEdit2,
IconExecute,
IconFitImage,
IconGrid,
IconImage,
@ -16,6 +21,7 @@ import {
import BadgeHelp from '@/components/info/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton';
import { HelpTopic } from '@/models/miscellaneous';
import { OperationType } from '@/models/oss';
import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/labels';
@ -28,6 +34,8 @@ interface ToolbarOssGraphProps {
edgeStraight: boolean;
onCreate: () => void;
onDelete: () => void;
onEdit: () => void;
onExecute: () => void;
onFitView: () => void;
onSaveImage: () => void;
onSavePositions: () => void;
@ -44,6 +52,8 @@ function ToolbarOssGraph({
edgeStraight,
onCreate,
onDelete,
onEdit,
onExecute,
onFitView,
onSaveImage,
onSavePositions,
@ -53,10 +63,40 @@ function ToolbarOssGraph({
toggleEdgeStraight
}: ToolbarOssGraphProps) {
const controller = useOssEdit();
const selectedOperation = useMemo(
() => controller.schema?.operationByID.get(controller.selected[0]),
[controller.selected, controller.schema]
);
const readyForSynthesis = useMemo(() => {
if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (!controller.schema || selectedOperation.result) {
return false;
}
const argumentIDs = controller.schema.graph.expandInputs([selectedOperation.id]);
if (!argumentIDs || argumentIDs.length < 2) {
return false;
}
const argumentOperations = argumentIDs.map(id => controller.schema!.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
}, [selectedOperation, controller.schema]);
return (
<div className='flex flex-col items-center'>
<div className='cc-icons'>
<MiniButton
title='Сбросить изменения'
icon={<IconReset size='1.25rem' className='icon-primary' />}
disabled={!isModified}
onClick={onResetPositions}
/>
<MiniButton
icon={<IconFitImage size='1.25rem' className='icon-primary' />}
title='Сбросить вид'
@ -108,7 +148,6 @@ function ToolbarOssGraph({
</div>
{controller.isMutable ? (
<div className='cc-icons'>
{' '}
<MiniButton
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}
icon={<IconSave size='1.25rem' className='icon-primary' />}
@ -116,19 +155,25 @@ function ToolbarOssGraph({
onClick={onSavePositions}
/>
<MiniButton
title='Сбросить изменения'
icon={<IconReset size='1.25rem' className='icon-primary' />}
disabled={!isModified}
onClick={onResetPositions}
/>
<MiniButton
title='Новая операция'
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={onCreate}
/>
<MiniButton
title='Удалить выбранную'
title='Выполнить операцию'
icon={<IconExecute size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing || controller.selected.length !== 1 || !readyForSynthesis}
onClick={onExecute}
/>
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Ctrl + клик')}
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
disabled={controller.selected.length !== 1 || controller.isProcessing}
onClick={onEdit}
/>
<MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={controller.selected.length !== 1 || controller.isProcessing}
onClick={onDelete}
@ -138,5 +183,5 @@ function ToolbarOssGraph({
</div>
);
}
//IconExecute
export default ToolbarOssGraph;

View File

@ -10,12 +10,21 @@ 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 DlgEditOperation from '@/dialogs/DlgEditOperation';
import { AccessPolicy, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationPosition, IOperationSchema, OperationID } from '@/models/oss';
import {
IOperationCreateData,
IOperationPosition,
IOperationSchema,
IOperationSetInputData,
IOperationUpdateData,
OperationID
} from '@/models/oss';
import { UserID, UserLevel } from '@/models/user';
import { information } from '@/utils/labels';
@ -45,6 +54,9 @@ 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;
promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void;
executeOperation: (target: OperationID, positions: IOperationPosition[]) => void;
}
const OssEditContext = createContext<IOssEditContext | null>(null);
@ -79,10 +91,17 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const [showEditEditors, setShowEditEditors] = useState(false);
const [showEditLocation, setShowEditLocation] = useState(false);
const [showEditInput, setShowEditInput] = useState(false);
const [showEditOperation, setShowEditOperation] = 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(
() =>
@ -197,9 +216,26 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const handleCreateOperation = useCallback(
(data: IOperationCreateData) => {
data.positions = positions;
data.item_data.position_x = insertPosition.x;
data.item_data.position_y = insertPosition.y;
model.createOperation(data, operation => toast.success(information.newOperation(operation.alias)));
},
[model]
[model, positions, insertPosition]
);
const promptEditOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowEditOperation(true);
}, []);
const handleEditOperation = useCallback(
(data: IOperationUpdateData) => {
data.positions = positions;
model.updateOperation(data, () => toast.success(information.changesSaved));
},
[model, positions]
);
const deleteOperation = useCallback(
@ -221,6 +257,38 @@ 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) => {
if (!targetOperationID) {
return;
}
const data: IOperationSetInputData = {
target: targetOperationID,
positions: positions,
input: newInput ?? null
};
model.setInput(data, () => toast.success(information.changesSaved));
},
[model, targetOperationID, positions]
);
const executeOperation = useCallback(
(target: OperationID, positions: IOperationPosition[]) => {
const data = {
target: target,
positions: positions
};
model.executeOperation(data, () => toast.success(information.operationExecuted));
},
[model]
);
return (
<OssEditContext.Provider
value={{
@ -246,7 +314,10 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
savePositions,
promptCreateOperation,
deleteOperation,
createInput
createInput,
promptEditInput,
promptEditOperation,
executeOperation
}}
>
{model.schema ? (
@ -269,11 +340,25 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
<DlgCreateOperation
hideWindow={() => setShowCreateOperation(false)}
oss={model.schema}
positions={positions}
insertPosition={insertPosition}
onCreate={handleCreateOperation}
/>
) : null}
{showEditInput ? (
<DlgChangeInputSchema
hideWindow={() => setShowEditInput(false)}
oss={model.schema}
target={targetOperation!}
onSubmit={setTargetInput}
/>
) : null}
{showEditOperation ? (
<DlgEditOperation
hideWindow={() => setShowEditOperation(false)}
oss={model.schema}
target={targetOperation!}
onSubmit={handleEditOperation}
/>
) : null}
</AnimatePresence>
) : null}

View File

@ -646,6 +646,7 @@ export const RSEditState = ({
) : null}
{showSubstitute ? (
<DlgSubstituteCst
schema={model.schema}
hideWindow={() => setShowSubstitute(false)} // prettier: split lines
onSubstitute={handleSubstituteCst}
/>

View File

@ -940,6 +940,8 @@ export const information = {
versionDestroyed: 'Версия удалена',
itemDestroyed: 'Схема удалена',
operationDestroyed: 'Операция удалена',
operationExecuted: 'Операция выполнена',
allOperationExecuted: 'Все операции выполнены',
constituentsDestroyed: (aliases: string) => `Конституенты удалены: ${aliases}`
};
@ -949,7 +951,8 @@ export const information = {
export const errors = {
astFailed: 'Невозможно построить дерево разбора',
passwordsMismatch: 'Пароли не совпадают',
imageFailed: 'Ошибка при создании изображения'
imageFailed: 'Ошибка при создании изображения',
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении'
};
/**