Compare commits

..

No commits in common. "5c4149337b6486b888cc88ed9b7700f24163905d" and "c05759901ae1d867571581db9483c32d2fdde7a6" have entirely different histories.

170 changed files with 2423 additions and 3775 deletions

View File

@ -1,73 +0,0 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
# SECURITY SENSITIVE FILES
secrets/
nginx/cert/*.pem
# External distributions
rsconcept/backend/import/*.whl
rsconcept/backend/static
rsconcept/backend/media
rsconcept/frontend/dist
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
eggs/
.eggs/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
.mypy_cache/
# Django
*.log
db.sqlite3
db.sqlite3-journal
visualizeDB.dot
# React
.DS_*
*.log
*.tsbuildinfo
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
# NextJS
**/.next/
**/out/
# Environments
venv/
/GitExtensions.settings
rsconcept/frontend/public/privacy.pdf
/rsconcept/frontend/playwright-report
/rsconcept/frontend/test-results

View File

@ -68,6 +68,8 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible'])
self.assertEqual(response.data['read_only'], data['read_only'])
self.assertEqual(oss.layout().data['operations'], [])
self.assertEqual(oss.layout().data['blocks'], [])
self.logout()
data = {'title': 'Title2'}

View File

@ -41,7 +41,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
else:
serializer.save()
if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA:
Layout.objects.create(oss=serializer.instance, data=[])
Layout.objects.create(oss=serializer.instance, data={'operations': [], 'blocks': []})
def perform_update(self, serializer) -> None:
instance = serializer.save()

View File

@ -1,41 +0,0 @@
from django.db import migrations
def migrate_layout(apps, schema_editor):
Layout = apps.get_model('oss', 'Layout')
for layout in Layout.objects.all():
previous_data = layout.data
new_layout = []
for operation in previous_data['operations']:
new_layout.append({
'nodeID': 'o' + str(operation['id']),
'x': operation['x'],
'y': operation['y'],
'width': 150,
'height': 40
})
for block in previous_data['blocks']:
new_layout.append({
'nodeID': 'b' + str(block['id']),
'x': block['x'],
'y': block['y'],
'width': block['width'],
'height': block['height']
})
layout.data = new_layout
layout.save(update_fields=['data'])
class Migration(migrations.Migration):
dependencies = [
('oss', '0011_remove_operation_position_x_and_more'),
]
operations = [
migrations.RunPython(migrate_layout),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.1 on 2025-06-11 10:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0012 restructure_layout'),
]
operations = [
migrations.AlterField(
model_name='layout',
name='data',
field=models.JSONField(default=list, verbose_name='Расположение'),
),
]

View File

@ -13,7 +13,7 @@ class Layout(Model):
data = JSONField(
verbose_name='Расположение',
default=list
default=dict
)
class Meta:

View File

@ -40,7 +40,7 @@ class OperationSchema:
def create(**kwargs) -> 'OperationSchema':
''' Create LibraryItem via OperationSchema. '''
model = LibraryItem.objects.create(item_type=LibraryItemType.OPERATION_SCHEMA, **kwargs)
Layout.objects.create(oss=model, data=[])
Layout.objects.create(oss=model, data={'operations': [], 'blocks': []})
return OperationSchema(model)
@staticmethod

View File

@ -2,9 +2,16 @@
from rest_framework import serializers
class NodeSerializer(serializers.Serializer):
class OperationNodeSerializer(serializers.Serializer):
''' Operation position. '''
id = serializers.IntegerField()
x = serializers.FloatField()
y = serializers.FloatField()
class BlockNodeSerializer(serializers.Serializer):
''' Block position. '''
nodeID = serializers.CharField()
id = serializers.IntegerField()
x = serializers.FloatField()
y = serializers.FloatField()
width = serializers.FloatField()
@ -12,8 +19,13 @@ class NodeSerializer(serializers.Serializer):
class LayoutSerializer(serializers.Serializer):
''' Serializer: Layout data. '''
data = serializers.ListField(child=NodeSerializer()) # type: ignore
''' Layout for OperationSchema. '''
blocks = serializers.ListField(
child=BlockNodeSerializer()
)
operations = serializers.ListField(
child=OperationNodeSerializer()
)
class SubstitutionExSerializer(serializers.Serializer):

View File

@ -13,7 +13,7 @@ from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg
from ..models import Argument, Block, Inheritance, Operation, OperationSchema, OperationType
from .basics import NodeSerializer, SubstitutionExSerializer
from .basics import LayoutSerializer, SubstitutionExSerializer
class OperationSerializer(serializers.ModelSerializer):
@ -52,9 +52,7 @@ class CreateBlockSerializer(serializers.Serializer):
model = Block
fields = 'title', 'description', 'parent'
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
item_data = BlockCreateData()
width = serializers.FloatField()
height = serializers.FloatField()
@ -102,10 +100,7 @@ class UpdateBlockSerializer(serializers.Serializer):
model = Block
fields = 'title', 'description', 'parent'
layout = serializers.ListField(
child=NodeSerializer(),
required=False
)
layout = LayoutSerializer(required=False)
target = PKField(many=False, queryset=Block.objects.all())
item_data = UpdateBlockData()
@ -132,9 +127,7 @@ class UpdateBlockSerializer(serializers.Serializer):
class DeleteBlockSerializer(serializers.Serializer):
''' Serializer: Delete block. '''
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
target = PKField(many=False, queryset=Block.objects.all().only('oss_id'))
def validate(self, attrs):
@ -149,9 +142,7 @@ class DeleteBlockSerializer(serializers.Serializer):
class MoveItemsSerializer(serializers.Serializer):
''' Serializer: Move items to another parent. '''
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent'))
blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent'))
destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True)
@ -205,12 +196,8 @@ class CreateOperationSerializer(serializers.Serializer):
'alias', 'operation_type', 'title', \
'description', 'result', 'parent'
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
item_data = CreateOperationData()
width = serializers.FloatField()
height = serializers.FloatField()
position_x = serializers.FloatField()
position_y = serializers.FloatField()
create_schema = serializers.BooleanField(default=False, required=False)
@ -243,10 +230,7 @@ class UpdateOperationSerializer(serializers.Serializer):
model = Operation
fields = 'alias', 'title', 'description', 'parent'
layout = serializers.ListField(
child=NodeSerializer(),
required=False
)
layout = LayoutSerializer(required=False)
target = PKField(many=False, queryset=Operation.objects.all())
item_data = UpdateOperationData()
arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
@ -313,9 +297,7 @@ class UpdateOperationSerializer(serializers.Serializer):
class DeleteOperationSerializer(serializers.Serializer):
''' Serializer: Delete operation. '''
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result'))
keep_constituents = serializers.BooleanField(default=False, required=False)
delete_schema = serializers.BooleanField(default=False, required=False)
@ -332,9 +314,7 @@ class DeleteOperationSerializer(serializers.Serializer):
class TargetOperationSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
def validate(self, attrs):
@ -349,9 +329,7 @@ class TargetOperationSerializer(serializers.Serializer):
class SetOperationInputSerializer(serializers.Serializer):
''' Serializer: Set input schema for operation. '''
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all())
input = PKField(
many=False,
@ -388,9 +366,7 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
substitutions = serializers.ListField(
child=SubstitutionExSerializer()
)
layout = serializers.ListField(
child=NodeSerializer()
)
layout = LayoutSerializer()
class Meta:
''' serializer metadata. '''
@ -483,7 +459,7 @@ class RelocateConstituentsSerializer(serializers.Serializer):
return attrs
# ====== Internals ============
# ====== Internals =================================================================================
def _collect_descendants(start_blocks: list[Block]) -> set[int]:

View File

@ -59,11 +59,14 @@ class TestChangeAttributes(EndpointTester):
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -57,11 +57,14 @@ class TestChangeConstituents(EndpointTester):
self.ks3 = RSForm(self.operation3.result)
self.assertEqual(self.ks3.constituents().count(), 4)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -107,13 +107,16 @@ class TestChangeOperations(EndpointTester):
convention='KS5D4'
)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
]
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
{'id': self.operation4.pk, 'x': 0, 'y': 0},
{'id': self.operation5.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -107,13 +107,16 @@ class TestChangeSubstitutions(EndpointTester):
convention='KS5D4'
)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
{'id': self.operation4.pk, 'x': 0, 'y': 0},
{'id': self.operation5.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()

View File

@ -49,14 +49,16 @@ class TestOssBlocks(EndpointTester):
title='3',
parent=self.block1
)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'b' + str(self.block1.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
{'nodeID': 'b' + str(self.block2.pk), 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
],
'blocks': [
{'id': self.block1.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
{'id': self.block2.pk, 'x': 0, 'y': 0, 'width': 0.5, 'height': 0.5},
]
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
@ -86,7 +88,7 @@ class TestOssBlocks(EndpointTester):
self.assertEqual(len(response.data['oss']['blocks']), 3)
new_block = response.data['new_block']
layout = response.data['oss']['layout']
item = [item for item in layout if item['nodeID'] == 'b' + str(new_block['id'])][0]
item = [item for item in layout['blocks'] if item['id'] == new_block['id']][0]
self.assertEqual(new_block['title'], data['item_data']['title'])
self.assertEqual(new_block['description'], data['item_data']['description'])
self.assertEqual(new_block['parent'], None)

View File

@ -54,11 +54,14 @@ class TestOssOperations(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
self.layout_data = {
'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
],
'blocks': []
}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
@ -84,9 +87,7 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data,
'position_x': 1,
'position_y': 1,
'width': 500,
'height': 50
'position_y': 1
}
self.executeBadData(data=data)
@ -101,7 +102,7 @@ class TestOssOperations(EndpointTester):
self.assertEqual(len(response.data['oss']['operations']), 4)
new_operation = response.data['new_operation']
layout = response.data['oss']['layout']
item = [item for item in layout if item['nodeID'] == 'o' + str(new_operation['id'])][0]
item = [item for item in layout['operations'] if item['id'] == new_operation['id']][0]
self.assertEqual(new_operation['alias'], data['item_data']['alias'])
self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type'])
self.assertEqual(new_operation['title'], data['item_data']['title'])
@ -110,8 +111,6 @@ class TestOssOperations(EndpointTester):
self.assertEqual(new_operation['parent'], None)
self.assertEqual(item['x'], data['position_x'])
self.assertEqual(item['y'], data['position_y'])
self.assertEqual(item['width'], data['width'])
self.assertEqual(item['height'], data['height'])
self.operation1.refresh_from_db()
self.executeForbidden(data=data, item=self.unowned_id)
@ -133,9 +132,7 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data,
'position_x': 1,
'position_y': 1,
'width': 500,
'height': 50
'position_y': 1
}
self.executeBadData(data=data, item=self.owned_id)
@ -163,8 +160,6 @@ class TestOssOperations(EndpointTester):
'layout': self.layout_data,
'position_x': 1,
'position_y': 1,
'width': 500,
'height': 50,
'arguments': [self.operation1.pk, self.operation3.pk]
}
response = self.executeCreated(data=data, item=self.owned_id)
@ -190,9 +185,7 @@ class TestOssOperations(EndpointTester):
},
'layout': self.layout_data,
'position_x': 1,
'position_y': 1,
'width': 500,
'height': 50
'position_y': 1
}
response = self.executeCreated(data=data, item=self.owned_id)
new_operation = response.data['new_operation']
@ -214,9 +207,7 @@ class TestOssOperations(EndpointTester):
'create_schema': True,
'layout': self.layout_data,
'position_x': 1,
'position_y': 1,
'width': 500,
'height': 50
'position_y': 1
}
self.executeBadData(data=data, item=self.owned_id)
data['item_data']['result'] = None
@ -253,7 +244,7 @@ class TestOssOperations(EndpointTester):
self.login()
response = self.executeOK(data=data)
layout = response.data['layout']
deleted_items = [item for item in layout if item['nodeID'] == 'o' + str(data['target'])]
deleted_items = [item for item in layout['operations'] if item['id'] == data['target']]
self.assertEqual(len(response.data['operations']), 2)
self.assertEqual(len(deleted_items), 0)

View File

@ -55,11 +55,11 @@ class TestOssViewset(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
]
self.layout_data = {'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
], 'blocks': []}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
@ -107,9 +107,10 @@ class TestOssViewset(EndpointTester):
self.assertEqual(arguments[1]['argument'], self.operation2.pk)
layout = response.data['layout']
self.assertEqual(layout[0], self.layout_data[0])
self.assertEqual(layout[1], self.layout_data[1])
self.assertEqual(layout[2], self.layout_data[2])
self.assertEqual(layout['blocks'], [])
self.assertEqual(layout['operations'][0], {'id': self.operation1.pk, 'x': 0, 'y': 0})
self.assertEqual(layout['operations'][1], {'id': self.operation2.pk, 'x': 0, 'y': 0})
self.assertEqual(layout['operations'][2], {'id': self.operation3.pk, 'x': 0, 'y': 0})
self.executeOK(item=self.unowned_id)
self.executeForbidden(item=self.private_id)
@ -125,21 +126,23 @@ class TestOssViewset(EndpointTester):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {'data': []}
data = {'operations': [], 'blocks': []}
self.executeOK(data=data)
data = {'data': [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 36.1, 'y': 1437, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40}
]}
data = {
'operations': [
{'id': self.operation1.pk, 'x': 42.1, 'y': 1337},
{'id': self.operation2.pk, 'x': 36.1, 'y': 1437},
{'id': self.operation3.pk, 'x': 36.1, 'y': 1435}
], 'blocks': []
}
self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned_id)
self.toggle_admin(False)
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.assertEqual(self.owned.layout().data, data['data'])
self.assertEqual(self.owned.layout().data, data)
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id)

View File

@ -91,7 +91,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
''' Endpoint: Update schema layout. '''
serializer = s.LayoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data['data'])
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -120,8 +120,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
children_operations: list[m.Operation] = serializer.validated_data['children_operations']
with transaction.atomic():
new_block = oss.create_block(**serializer.validated_data['item_data'])
layout.append({
'nodeID': 'b' + str(new_block.pk),
layout['blocks'].append({
'id': new_block.pk,
'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
@ -205,7 +205,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object())
block = cast(m.Block, serializer.validated_data['target'])
layout = serializer.validated_data['layout']
layout = [x for x in layout if x['nodeID'] != 'b' + str(block.pk)]
layout['blocks'] = [x for x in layout['blocks'] if x['id'] != block.pk]
with transaction.atomic():
oss.delete_block(block)
oss.update_layout(layout)
@ -274,12 +274,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = serializer.validated_data['layout']
with transaction.atomic():
new_operation = oss.create_operation(**serializer.validated_data['item_data'])
layout.append({
'nodeID': 'o' + str(new_operation.pk),
layout['operations'].append({
'id': new_operation.pk,
'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
'height': serializer.validated_data['height']
'y': serializer.validated_data['position_y']
})
oss.update_layout(layout)
@ -344,9 +342,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation.title = serializer.validated_data['item_data']['title']
if 'description' in serializer.validated_data['item_data']:
operation.description = serializer.validated_data['item_data']['description']
if 'parent' in serializer.validated_data['item_data']:
operation.parent = serializer.validated_data['item_data']['parent']
operation.save(update_fields=['alias', 'title', 'description', 'parent'])
operation.save(update_fields=['alias', 'title', 'description'])
if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result)
@ -388,7 +384,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation = cast(m.Operation, serializer.validated_data['target'])
old_schema = operation.result
layout = serializer.validated_data['layout']
layout = [x for x in layout if x['nodeID'] != 'o' + str(operation.pk)]
layout['operations'] = [x for x in layout['operations'] if x['id'] != operation.pk]
with transaction.atomic():
oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents'])
oss.update_layout(layout)

View File

@ -7475,44 +7475,36 @@
"pk": 1,
"fields": {
"oss": 41,
"data": [
"data": {
"blocks": [],
"operations": [
{
"x": 530.0,
"y": 370.0,
"nodeID": "o1",
"width": 150.0,
"height": 40.0
"id": 1
},
{
"x": 710.0,
"y": 370.0,
"nodeID": "o2",
"width": 150.0,
"height": 40.0
"id": 2
},
{
"x": 890.0,
"y": 370.0,
"nodeID": "o4",
"width": 150.0,
"height": 40.0
"id": 4
},
{
"x": 620.0,
"y": 470.0,
"nodeID": "o9",
"width": 150.0,
"height": 40.0
"id": 9
},
{
"x": 760.0,
"y": 570.0,
"nodeID": "o10",
"width": 150.0,
"height": 40.0
"id": 10
}
]
}
}
}
]

View File

@ -29,18 +29,6 @@
</head>
<body>
<div id="root"></div>
<script>
try {
const preferences = JSON.parse(localStorage.getItem('portal.preferences') || '{}').state;
const isDark = preferences ? preferences.darkMode : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
document.documentElement.setAttribute('data-color-scheme', isDark ? 'dark' : 'light');
} catch (e) {}
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -14,73 +14,73 @@
"preview": "vite preview --port 3000"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.5",
"@hookform/resolvers": "^5.1.1",
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@lezer/lr": "^1.4.2",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.81.5",
"@tanstack/react-query": "^5.80.5",
"@tanstack/react-query-devtools": "^5.80.5",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.23.14",
"@uiw/react-codemirror": "^4.23.14",
"axios": "^1.10.0",
"@uiw/codemirror-themes": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"global": "^4.4.0",
"js-file-download": "^0.4.12",
"lucide-react": "^0.525.0",
"lucide-react": "^0.511.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.59.0",
"react-hook-form": "^7.57.0",
"react-icons": "^5.5.0",
"react-intl": "^7.1.11",
"react-router": "^7.6.3",
"react-scan": "^0.3.6",
"react-router": "^7.6.2",
"react-scan": "^0.3.4",
"react-tabs": "^6.1.0",
"react-toastify": "^11.0.5",
"react-tooltip": "^5.29.1",
"react-tooltip": "^5.28.1",
"react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.3.4",
"use-debounce": "^10.0.5",
"zod": "^3.25.67",
"zustand": "^5.0.6"
"use-debounce": "^10.0.4",
"zod": "^3.25.51",
"zustand": "^5.0.5"
},
"devDependencies": {
"@lezer/generator": "^1.8.0",
"@playwright/test": "^1.53.2",
"@tailwindcss/vite": "^4.1.11",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.8",
"@types/react": "^19.1.8",
"@lezer/generator": "^1.7.3",
"@playwright/test": "^1.52.0",
"@tailwindcss/vite": "^4.1.8",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.6.0",
"@vitejs/plugin-react": "^4.5.1",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"eslint": "^9.30.0",
"eslint-plugin-import": "^2.32.0",
"eslint": "^9.28.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.3.0",
"jest": "^30.0.3",
"stylelint": "^16.21.0",
"globals": "^16.2.0",
"jest": "^29.7.0",
"stylelint": "^16.20.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^4.0.7",
"ts-jest": "^29.4.0",
"ts-jest": "^29.3.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.0"
"typescript-eslint": "^8.33.1",
"vite": "^6.3.5"
},
"jest": {
"preset": "ts-jest",

View File

@ -14,10 +14,10 @@ export function Footer() {
)}
>
<nav className='flex gap-3' aria-label='Вторичная навигация'>
<TextURL text='Библиотека' href='/library' color='hover:text-foreground' />
<TextURL text='Справка' href='/manuals' color='hover:text-foreground' />
<TextURL text='Центр Концепт' href={external_urls.concept} color='hover:text-foreground' />
<TextURL text='Экстеор' href='/manuals?topic=exteor' color='hover:text-foreground' />
<TextURL text='Библиотека' href='/library' color='' />
<TextURL text='Справка' href='/manuals' color='' />
<TextURL text='Центр Концепт' href={external_urls.concept} color='' />
<TextURL text='Экстеор' href='/manuals?topic=exteor' color='' />
</nav>
<p>© 2025 ЦИВТ КОНЦЕПТ</p>

View File

@ -10,9 +10,8 @@ export const GlobalTooltips = () => {
float
id={globalIDs.tooltip}
layer='z-topmost'
place='bottom-start'
offset={24}
className='max-w-80 break-words rounded-lg! select-none'
place='right-start'
className='mt-8 max-w-80 break-words rounded-lg!'
/>
<Tooltip
float

View File

@ -25,12 +25,8 @@ export function ToggleNavigation() {
data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
aria-label={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
>
{!noNavigationAnimation ? (
<IconPin size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
{noNavigationAnimation ? (
<IconUnpin size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
{!noNavigationAnimation ? <IconPin size='0.75rem' /> : null}
{noNavigationAnimation ? <IconUnpin size='0.75rem' /> : null}
</button>
{!noNavigationAnimation ? (
<button
@ -42,12 +38,8 @@ export function ToggleNavigation() {
data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
aria-label={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
>
{darkMode ? (
<IconDarkTheme size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
{!darkMode ? (
<IconLightTheme size='0.75rem' className='hover:text-primary cc-animate-color cc-hover-pulse' />
) : null}
{darkMode ? <IconDarkTheme size='0.75rem' /> : null}
{!darkMode ? <IconLightTheme size='0.75rem' /> : null}
</button>
) : null}
</div>

View File

@ -18,6 +18,7 @@ export function UserButton({ onLogin, onClickUser, isOpen }: UserButtonProps) {
if (isAnonymous) {
return (
<NavigationButton
className='cc-fade-in'
title='Перейти на страницу логина'
icon={<IconLogin size='1.5rem' className='icon-primary' />}
onClick={onLogin}
@ -26,6 +27,7 @@ export function UserButton({ onLogin, onClickUser, isOpen }: UserButtonProps) {
} else {
return (
<NavigationButton
className='cc-fade-in'
title='Пользователь'
hideTitle={isOpen}
aria-haspopup='true'

View File

@ -40,7 +40,7 @@ export function Button({
className={cn(
'inline-flex gap-2 items-center justify-center',
'font-medium select-none disabled:cursor-auto disabled:opacity-75',
'bg-secondary text-secondary-foreground cc-hover-bg cc-animate-color',
'bg-secondary text-secondary-foreground cc-hover cc-animate-color',
dense ? 'px-1' : 'px-3 py-1',
loading ? 'cursor-progress' : 'cursor-pointer',
noOutline ? 'outline-hidden focus-visible:bg-selected' : 'focus-outline',

View File

@ -1,7 +1,8 @@
import clsx from 'clsx';
import { globalIDs } from '@/utils/constants';
import { type Button } from '../props';
import { cn } from '../utils';
interface MiniButtonProps extends Button {
/** Button type. */
@ -36,12 +37,11 @@ export function MiniButton({
<button
type={type}
tabIndex={tabIndex ?? -1}
className={cn(
className={clsx(
'rounded-lg',
'text-muted-foreground cc-animate-color',
'cursor-pointer disabled:cursor-auto disabled:opacity-75',
(!tabIndex || tabIndex === -1) && 'outline-hidden',
!noHover && 'cc-hover-pulse',
'cc-controls cc-animate-background',
'cursor-pointer disabled:cursor-auto',
noHover ? 'outline-hidden' : 'cc-hover',
!noPadding && 'px-1 py-1',
className
)}

View File

@ -30,9 +30,9 @@ export function SelectorButton({
className={cn(
'px-1 flex flex-start items-center gap-1',
'text-sm font-controls select-none',
'disabled:cursor-auto cursor-pointer outline-hidden',
'text-muted-foreground cc-hover-text cc-animate-color disabled:opacity-75',
!text && 'cc-hover-pulse',
'text-btn cc-controls',
'disabled:cursor-auto cursor-pointer',
'cc-hover cc-animate-color',
className
)}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}

View File

@ -20,10 +20,8 @@ export function PaginationTools<TData>({
onChangePaginationOption,
paginationOptions
}: PaginationToolsProps<TData>) {
const buttonClass =
'cc-hover-text cc-animate-color focus-outline rounded-md disabled:opacity-75 not-[:disabled]:cursor-pointer';
return (
<div className='flex justify-end items-center my-2 text-muted-foreground text-sm select-none'>
<div className='flex justify-end items-center my-2 text-sm cc-controls select-none'>
<span className='mr-3'>
{`${table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}
-
@ -38,7 +36,7 @@ export function PaginationTools<TData>({
<button
type='button'
aria-label='Первая страница'
className={buttonClass}
className='cc-hover cc-controls cc-animate-color focus-outline'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
@ -47,7 +45,7 @@ export function PaginationTools<TData>({
<button
type='button'
aria-label='Предыдущая страница'
className={buttonClass}
className='cc-hover cc-controls cc-animate-color focus-outline'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
@ -57,7 +55,7 @@ export function PaginationTools<TData>({
id={id ? `${id}__page` : undefined}
title='Номер страницы. Выделите для ручного ввода'
aria-label='Номер страницы'
className='w-6 text-center bg-transparent focus-outline rounded-md'
className='w-6 text-center bg-transparent focus-outline'
value={table.getState().pagination.pageIndex + 1}
onChange={event => {
const page = event.target.value ? Number(event.target.value) - 1 : 0;
@ -69,7 +67,7 @@ export function PaginationTools<TData>({
<button
type='button'
aria-label='Следующая страница'
className={buttonClass}
className='cc-hover cc-controls cc-animate-color focus-outline'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
@ -78,7 +76,7 @@ export function PaginationTools<TData>({
<button
type='button'
aria-label='Последняя страница'
className={buttonClass}
className='cc-hover cc-controls cc-animate-color focus-outline'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>

View File

@ -3,7 +3,6 @@
import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table';
import clsx from 'clsx';
import { prefixes } from '@/utils/constants';
@ -31,12 +30,7 @@ export function SelectPagination<TData>({ id, table, paginationOptions, onChange
<SelectTrigger
id={id}
aria-label='Выбор количества строчек на странице'
className={clsx(
'w-28 max-h-6 mx-2',
'px-2 justify-end',
'bg-transparent cc-hover-text cc-animate-color focus-outline border-0 rounded-md',
'cursor-pointer'
)}
className='mx-2 cursor-pointer bg-transparent focus-outline border-0 w-28 max-h-6 px-2 justify-end'
>
<SelectValue />
</SelectTrigger>

View File

@ -70,7 +70,7 @@ export function TableRow<TData>({
<tr
className={cn(
'cc-scroll-row',
'cc-hover-bg cc-animate-background duration-fade',
'cc-hover cc-animate-background duration-fade',
!noHeader && 'scroll-mt-[calc(2px+2rem)]',
table.options.enableRowSelection && row.getIsSelected()
? 'cc-selected'

View File

@ -36,9 +36,9 @@ export function DropdownButton({
className={cn(
'px-3 py-1 inline-flex items-center gap-2',
'text-left text-sm text-ellipsis whitespace-nowrap',
'disabled:text-muted-foreground disabled:opacity-75',
'disabled:cc-controls disabled:opacity-75',
'focus-outline cc-animate-background',
!!onClick ? 'cc-hover-bg cursor-pointer disabled:cursor-auto' : 'bg-secondary text-secondary-foreground',
!!onClick ? 'cc-hover cursor-pointer disabled:cursor-auto' : 'bg-secondary text-secondary-foreground',
className
)}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}

View File

@ -25,10 +25,6 @@ export { LuQrCode as IconQR } from 'react-icons/lu';
export { LuFilterX as IconFilterReset } from 'react-icons/lu';
export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi';
export { LuTriangleAlert as IconAlert } from 'react-icons/lu';
export { LuPanelLeftOpen as IconLeftOpen } from 'react-icons/lu';
export { LuPanelLeftClose as IconLeftClose } from 'react-icons/lu';
export { LuPanelBottomOpen as IconBottomOpen } from 'react-icons/lu';
export { LuPanelBottomClose as IconBottomClose } from 'react-icons/lu';
// ===== UI elements =======
export { BiX as IconClose } from 'react-icons/bi';
@ -101,7 +97,9 @@ export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuView as IconDBStructure } from 'react-icons/lu';
export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu';
export { LuImage as IconImage } from 'react-icons/lu';
export { TbColumns as IconList } from 'react-icons/tb';
export { GoVersions as IconVersions } from 'react-icons/go';
export { TbColumnsOff as IconListOff } from 'react-icons/tb';
export { LuAtSign as IconTerm } from 'react-icons/lu';
export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb';
@ -109,8 +107,7 @@ export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi';
export { TbCircleLetterM as IconTypeGraph } from 'react-icons/tb';
export { RiTreeLine as IconTree } from 'react-icons/ri';
export { LuKeyboard as IconKeyboard } from 'react-icons/lu';
export { LuKeyboardOff as IconKeyboardOff } from 'react-icons/lu';
export { FaRegKeyboard as IconControls } from 'react-icons/fa6';
export { RiLockLine as IconImmutable } from 'react-icons/ri';
export { RiLockUnlockLine as IconMutable } from 'react-icons/ri';
export { RiOpenSourceLine as IconPublic } from 'react-icons/ri';
@ -144,6 +141,7 @@ export { GrConnect as IconConnect } from 'react-icons/gr';
export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI =======
export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';

View File

@ -92,12 +92,7 @@ export function ComboBox<Option>({
hidden={hidden && !open}
>
<span className='truncate'>{value ? labelValueFunc(value) : placeholder}</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground cc-hover-pulse hover:text-primary',
clearable && !!value && 'opacity-0'
)}
/>
<ChevronDownIcon className={cn('text-muted-foreground', clearable && !!value && 'opacity-0')} />
{clearable && !!value ? (
<IconRemove
tabIndex={-1}

View File

@ -115,7 +115,7 @@ export function ComboMulti<Option>({
<IconRemove
tabIndex={-1}
size='1rem'
className='cc-remove absolute pointer-events-auto right-3 cc-hover-pulse hover:text-primary'
className='cc-remove absolute pointer-events-auto right-3'
onClick={handleClear}
/>
) : null}

View File

@ -1,7 +1,7 @@
import { type FieldError, type GlobalError } from 'react-hook-form';
import clsx from 'clsx';
import { type Styling } from '../props';
import { cn } from '../utils';
interface ErrorFieldProps extends Styling {
error?: FieldError | GlobalError;
@ -15,7 +15,7 @@ export function ErrorField({ error, className, ...restProps }: ErrorFieldProps):
return null;
}
return (
<div className={cn('text-sm text-destructive select-none', className)} {...restProps}>
<div className={clsx('text-sm text-destructive select-none', className)} {...restProps}>
{error.message}
</div>
);

View File

@ -90,7 +90,7 @@ export function SelectTree<ItemType>({
<div
key={`${prefix}${index}`}
className={clsx(
'cc-tree-item relative cc-scroll-row cc-hover-bg',
'cc-tree-item relative cc-scroll-row cc-hover',
isActive ? 'max-h-7 py-1 border-b' : 'max-h-0 opacity-0 pointer-events-none',
value === item && 'cc-selected'
)}
@ -101,8 +101,9 @@ export function SelectTree<ItemType>({
{foldable.has(item) ? (
<MiniButton
aria-label={!folded.includes(item) ? 'Свернуть' : 'Развернуть'}
className={clsx('absolute left-1 hover:text-primary', !folded.includes(item) ? 'top-1.5' : 'top-1')}
className={clsx('absolute left-1', !folded.includes(item) ? 'top-1.5' : 'top-1')}
noPadding
noHover
icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
onClick={event => handleClickFold(event, item)}
/>

View File

@ -46,7 +46,7 @@ function SelectTrigger({
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className='size-4 cc-hover-pulse hover:text-primary' />
<ChevronDownIcon className='size-4' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@ -154,7 +154,7 @@ function SelectScrollDownButton({
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className='size-4 cc-hover-pulse hover:text-primary' />
<ChevronDownIcon className='size-4' />
</SelectPrimitive.ScrollDownButton>
);
}

View File

@ -8,7 +8,7 @@ export function ModalBackdrop({ onHide }: ModalBackdropProps) {
return (
<>
<div className='z-bottom fixed inset-0 backdrop-blur-[3px] opacity-50' />
<div className='z-bottom fixed inset-0 bg-foreground opacity-5' onClick={onHide} />
<div className='z-bottom fixed inset-0 bg-popover opacity-25' onClick={onHide} />
</>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import { type HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { BadgeHelp } from '@/features/help/components';
import { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs';
@ -89,7 +89,7 @@ export function ModalForm({
<div className='cc-modal-wrapper'>
<ModalBackdrop onHide={handleCancel} />
<form
className='cc-animate-modal relative grid border-2 px-1 pb-1 rounded-xl bg-background'
className='cc-animate-modal relative grid border rounded-xl bg-background'
role='dialog'
onSubmit={handleSubmit}
aria-labelledby='modal-title'

View File

@ -6,7 +6,7 @@ export function ModalLoader() {
return (
<div className='cc-modal-wrapper'>
<ModalBackdrop />
<div className='cc-animate-modal p-20 border-2 rounded-xl bg-background'>
<div className='cc-animate-modal p-20 border rounded-xl bg-background'>
<Loader circular scale={6} />
</div>
</div>

View File

@ -2,7 +2,7 @@
import clsx from 'clsx';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { BadgeHelp } from '@/features/help/components';
import { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs';
@ -39,7 +39,7 @@ export function ModalView({
return (
<div className='cc-modal-wrapper'>
<ModalBackdrop onHide={hideDialog} />
<div className='cc-animate-modal relative grid border-2 px-1 pb-1 rounded-xl bg-background' role='dialog'>
<div className='cc-animate-modal relative grid border rounded-xl bg-background' role='dialog'>
{helpTopic && !hideHelpWhen?.() ? (
<BadgeHelp
topic={helpTopic}

View File

@ -22,7 +22,6 @@ export function TabLabel({
className,
disabled,
role = 'tab',
selectedClassName = 'text-foreground! bg-secondary',
...otherProps
}: TabLabelProps) {
return (
@ -30,12 +29,12 @@ export function TabLabel({
className={clsx(
'min-w-20 h-full',
'px-2 py-1 flex justify-center',
'cc-animate-color duration-select text-muted-foreground',
'cc-animate-color duration-select',
'text-sm whitespace-nowrap font-controls',
'select-none',
'outline-hidden',
!disabled && 'hover:cursor-pointer cc-hover-text',
disabled && 'bg-secondary',
!disabled && 'hover:cursor-pointer cc-hover',
disabled && 'text-muted-foreground',
className
)}
tabIndex='-1'
@ -45,7 +44,6 @@ export function TabLabel({
data-tooltip-hidden={hideTitle}
role={role}
disabled={disabled}
selectedClassName={selectedClassName}
{...otherProps}
>
{label}

View File

@ -1,8 +1,8 @@
import clsx from 'clsx';
import { type Styling, type Titled } from '@/components/props';
import { globalIDs } from '@/utils/constants';
import { cn } from '../utils';
interface IndicatorProps extends Titled, Styling {
/** Icon to display. */
icon: React.ReactNode;
@ -17,8 +17,8 @@ interface IndicatorProps extends Titled, Styling {
export function Indicator({ icon, title, titleHtml, hideTitle, noPadding, className, ...restProps }: IndicatorProps) {
return (
<div
className={cn(
'text-muted-foreground', //
className={clsx(
'cc-controls', //
'outline-hidden',
!noPadding && 'px-1 py-1',
className

View File

@ -57,7 +57,7 @@ export function ValueIcon({
data-tooltip-hidden={hideTitle}
aria-label={title}
>
{onClick ? <MiniButton noPadding icon={icon} onClick={onClick} disabled={disabled} /> : icon}
{onClick ? <MiniButton noHover noPadding icon={icon} onClick={onClick} disabled={disabled} /> : icon}
<span id={id}>{value}</span>
</div>
);

View File

@ -0,0 +1,2 @@
export { ExpectedAnonymous } from './expected-anonymous';
export { RequireAuth } from './require-auth';

View File

@ -42,7 +42,7 @@ export function BadgeHelp({ topic, padding = 'p-1', className, contentClass, sty
}
return (
<div tabIndex={-1} id={`help-${topic}`} className={cn(padding, className)} style={style}>
<IconHelp size='1.25rem' className='text-muted-foreground hover:text-primary cc-animate-color' />
<IconHelp size='1.25rem' className='icon-primary' />
<Tooltip
clickable
anchorSelect={`#help-${topic}`}

View File

@ -1 +1 @@
export { BadgeHelp } from './badge-help';

View File

@ -12,30 +12,26 @@ export function HelpFormulaTree() {
</ul>
<h2>Виды узлов</h2>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-green)' />
объявление идентификатора
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-teal)' />
глобальный идентификатор
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-orange)' />
логическое выражение
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-blue)' />
типизированное выражение
</p>
<p className='m-0'>
<span className='cc-sample-color bg-(--acc-bg-red)' />
присвоение и итерация
</p>
<p className='m-0'>
<span className='cc-sample-color bg-secondary' />
составные выражения
</p>
<ul>
<li>
<span className='bg-(--acc-bg-green)'>объявление идентификатора</span>
</li>
<li>
<span className='bg-(--acc-bg-teal)'>глобальный идентификатор</span>
</li>
<li>
<span className='bg-(--acc-bg-orange)'>логическое выражение</span>
</li>
<li>
<span className='bg-(--acc-bg-blue)'>типизированное выражение</span>
</li>
<li>
<span className='bg-(--acc-bg-red)'>присвоение и итерация</span>
</li>
<li>
<span className='bg-secondary'>составные выражения</span>
</li>
</ul>
<h2>Команды</h2>
<ul>

View File

@ -62,7 +62,7 @@ export function HelpLibrary() {
<IconFilterReset size='1rem' className='inline-icon' /> сбросить фильтры
</li>
<li>
<IconFolderTree size='1rem' className='inline-icon' /> переключение между Проводник и Таблица
<IconFolderTree size='1rem' className='inline-icon' /> переключение между Проводник и Поиск
</li>
</ul>

View File

@ -11,8 +11,8 @@ import {
IconEdit2,
IconExecute,
IconFitImage,
IconFixLayout,
IconGrid,
IconLeftOpen,
IconLineStraight,
IconLineWave,
IconNewItem,
@ -31,7 +31,7 @@ export function HelpOssGraph() {
<div className='flex flex-col'>
<h1 className='sm:pr-24'>Граф синтеза</h1>
<div className='flex flex-col sm:flex-row'>
<div className='sm:w-64'>
<div className='sm:w-56'>
<h2>Настройка графа</h2>
<ul>
<li>
@ -41,7 +41,7 @@ export function HelpOssGraph() {
<IconFitImage className='inline-icon' /> Вписать в экран
</li>
<li>
<IconLeftOpen className='inline-icon' /> Панель связанной КС
<IconFixLayout className='inline-icon' /> Исправить расположения
</li>
<li>
<IconSettings className='inline-icon' /> Диалог настроек
@ -70,7 +70,7 @@ export function HelpOssGraph() {
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='sm:w-76'>
<div className='sm:w-84'>
<h2>Изменение узлов</h2>
<ul>
<li>
@ -101,7 +101,7 @@ export function HelpOssGraph() {
<Divider margins='my-2' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='sm:w-64'>
<div className='sm:w-56'>
<h2>Общие</h2>
<ul>
<li>
@ -111,14 +111,14 @@ export function HelpOssGraph() {
<kbd>Space</kbd> перемещение экрана
</li>
<li>
<kbd>Shift</kbd> перемещение в границах блока
<kbd>Shift</kbd> перемещение выделенных элементов в границах родителя
</li>
</ul>
</div>
<Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-76'>
<div className='dense w-84'>
<h2>Контекстное меню</h2>
<ul>
<li>

View File

@ -1,8 +1,9 @@
import {
IconClone,
IconDestroy,
IconDownload,
IconEditor,
IconImmutable,
IconLeftOpen,
IconOSS,
IconOwner,
IconPublic,
@ -15,17 +16,17 @@ import { HelpTopic } from '../../models/help-topic';
export function HelpRSCard() {
return (
<div className='dense'>
<h1>Паспорт схемы</h1>
<h1>Карточка схемы</h1>
<p>Паспорт содержит общую информацию и статистику</p>
<p>Карточка содержит общую информацию и статистику</p>
<p>
Паспорт позволяет управлять атрибутами и <LinkTopic text='версиями' topic={HelpTopic.VERSIONS} />
Карточка позволяет управлять атрибутами и <LinkTopic text='версиями' topic={HelpTopic.VERSIONS} />
</p>
<p>
Паспорт позволяет назначать <IconEditor className='inline-icon' /> Редакторов
Карточка позволяет назначать <IconEditor className='inline-icon' /> Редакторов
</p>
<p>
Паспорт позволяет изменить <IconOwner className='inline-icon icon-green' /> Владельца
Карточка позволяет изменить <IconOwner className='inline-icon icon-green' /> Владельца
</p>
<h2>Управление</h2>
@ -49,10 +50,13 @@ export function HelpRSCard() {
<IconImmutable className='inline-icon' /> Неизменные схемы
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> Удалить полностью удаляет схему из базы Портала
<IconClone className='inline-icon icon-green' /> Клонировать создать копию схемы
</li>
<li>
<IconLeftOpen className='inline-icon' /> Отображение статистики
<IconDownload className='inline-icon' /> Загрузить/Выгрузить взаимодействие с Экстеор
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> Удалить полностью удаляет схему из базы Портала
</li>
</ul>
</div>

View File

@ -1,11 +1,11 @@
import {
IconChild,
IconClone,
IconControls,
IconDestroy,
IconEdit,
IconFilter,
IconKeyboard,
IconLeftOpen,
IconList,
IconMoveDown,
IconMoveUp,
IconNewItem,
@ -37,7 +37,7 @@ export function HelpRSEditor() {
<IconPredecessor className='inline-icon' /> переход к исходной
</li>
<li>
<IconLeftOpen className='inline-icon' /> список конституент
<IconList className='inline-icon' /> список конституент
</li>
<li>
<IconSave className='inline-icon' /> сохранить: <kbd>Ctrl + S</kbd>
@ -72,16 +72,17 @@ export function HelpRSEditor() {
<IconChild className='inline-icon' /> отображение наследованных
</li>
<li>
<span className='cc-sample-color bg-selected' />
выбранная конституента
<span className='bg-selected'>текущая конституента</span>
</li>
<li>
<span className='cc-sample-color bg-accent-green50' />
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> выбранной
<span className='bg-accent-green50'>
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
<li>
<span className='cc-sample-color bg-accent-orange50' />
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> выбранной
<span className='bg-accent-orange50'>
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
</ul>
</div>
@ -93,7 +94,7 @@ export function HelpRSEditor() {
<IconStatusOK className='inline-icon' /> индикатор статуса определения сверху
</li>
<li>
<IconKeyboard className='inline-icon' /> специальная клавиатура и горячие клавиши
<IconControls className='inline-icon' /> специальная клавиатура и горячие клавиши
</li>
<li>
<IconTypeGraph className='inline-icon' /> отображение{' '}

View File

@ -32,17 +32,18 @@ export function HelpRSMenu() {
<h2>Вкладки</h2>
<ul>
<li>
<LinkTopic text='Паспорт' topic={HelpTopic.UI_RS_CARD} /> редактирование атрибутов схемы и версии
<LinkTopic text='Карточка' topic={HelpTopic.UI_RS_CARD} /> редактирование атрибутов схемы и версии
</li>
<li>
<LinkTopic text='Список' topic={HelpTopic.UI_RS_LIST} /> работа со списком конституент в табличной форме
<LinkTopic text='Содержание' topic={HelpTopic.UI_RS_LIST} /> работа со списком конституент в табличной форме
</li>
<li>
<LinkTopic text='Понятие' topic={HelpTopic.UI_RS_EDITOR} /> редактирование отдельной{' '}
<LinkTopic text='Редактор' topic={HelpTopic.UI_RS_EDITOR} /> редактирование отдельной{' '}
<LinkTopic text='Конституенты' topic={HelpTopic.CC_CONSTITUENTA} />
</li>
<li>
<LinkTopic text='Граф' topic={HelpTopic.UI_GRAPH_TERM} /> графическое представление связей конституент
<LinkTopic text='Граф термов' topic={HelpTopic.UI_GRAPH_TERM} /> графическое представление связей
конституент
</li>
</ul>

View File

@ -23,18 +23,17 @@ export function HelpTypeGraph() {
<h2>Цвета узлов</h2>
<p className='m-0'>
<span className='cc-sample-color bg-secondary' />
ступень-основание
</p>
<p className='m-0'>
<span className='cc-sample-color bg-accent-teal' />
ступень-булеан
</p>
<p className='m-0'>
<span className='cc-sample-color bg-accent-orange' />
ступень декартова произведения
</p>
<ul>
<li>
<span className='bg-secondary'>ступень-основание</span>
</li>
<li>
<span className='bg-accent-teal'>ступень-булеан</span>
</li>
<li>
<span className='bg-accent-orange'>ступень декартова произведения</span>
</li>
</ul>
<h2>Команды</h2>
<ul>

View File

@ -13,7 +13,7 @@ export function labelHelpTopic(topic: HelpTopic): string {
case HelpTopic.INTERFACE: return '🌀 Интерфейс';
case HelpTopic.UI_LIBRARY: return 'Библиотека';
case HelpTopic.UI_RS_MENU: return 'Меню схемы';
case HelpTopic.UI_RS_CARD: return 'Паспорт схемы';
case HelpTopic.UI_RS_CARD: return 'Карточка схемы';
case HelpTopic.UI_RS_LIST: return 'Список конституент';
case HelpTopic.UI_RS_EDITOR: return 'Редактор конституенты';
case HelpTopic.UI_GRAPH_TERM: return 'Граф термов';

View File

@ -3,8 +3,7 @@ import { useIntl } from 'react-intl';
import { urls, useConceptNavigation } from '@/app';
import { useLabelUser, useRoleStore, UserRole } from '@/features/users';
import { InfoUsers } from '@/features/users/components/info-users';
import { SelectUser } from '@/features/users/components/select-user';
import { InfoUsers, SelectUser } from '@/features/users/components';
import { Tooltip } from '@/components/container';
import { MiniButton } from '@/components/control';
@ -87,6 +86,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
<div className='relative flex justify-stretch sm:mb-1 max-w-120 gap-3'>
<MiniButton
title='Открыть в библиотеке'
noHover
noPadding
icon={<IconFolderOpened size='1.25rem' className='icon-primary' />}
onClick={handleOpenLibrary}
@ -137,14 +137,14 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
<ValueIcon
title='Дата обновления'
dense
icon={<IconDateUpdate size='1.25rem' />}
icon={<IconDateUpdate size='1.25rem' className='text-constructive' />}
value={new Date(schema.time_update).toLocaleString(intl.locale)}
/>
<ValueIcon
title='Дата создания'
dense
icon={<IconDateCreate size='1.25rem' />}
icon={<IconDateCreate size='1.25rem' className='text-constructive' />}
value={new Date(schema.time_create).toLocaleString(intl.locale, {
year: '2-digit',
month: '2-digit',

View File

@ -1,16 +1,21 @@
import { UserRole } from '@/features/users';
import { type DomIconProps, IconAdmin, IconEditor, IconOwner, IconReader } from '@/components/icons';
import { IconAdmin, IconEditor, IconOwner, IconReader } from '@/components/icons';
export function IconRole({ value, size = '1.25rem', className }: DomIconProps<UserRole>) {
switch (value) {
interface IconRoleProps {
role: UserRole;
size?: string;
}
export function IconRole({ role, size = '1.25rem' }: IconRoleProps) {
switch (role) {
case UserRole.ADMIN:
return <IconAdmin size={size} className={className ?? 'icon-primary'} />;
return <IconAdmin size={size} className='icon-primary' />;
case UserRole.OWNER:
return <IconOwner size={size} className={className ?? 'icon-primary'} />;
return <IconOwner size={size} className='icon-primary' />;
case UserRole.EDITOR:
return <IconEditor size={size} className={className ?? 'icon-primary'} />;
return <IconEditor size={size} className='icon-primary' />;
case UserRole.READER:
return <IconReader size={size} className={className ?? 'icon-primary'} />;
return <IconReader size={size} className='icon-primary' />;
}
}

View File

@ -1,23 +0,0 @@
import { type DomIconProps, IconBottomClose, IconBottomOpen, IconLeftClose, IconLeftOpen } from '@/components/icons';
/** Icon for sidebar visibility. */
export function IconShowSidebar({
value,
size = '1.25rem',
className,
isBottom
}: DomIconProps<boolean> & { isBottom: boolean }) {
if (isBottom) {
if (value) {
return <IconBottomClose size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconBottomOpen size={size} className={className ?? 'icon-primary'} />;
}
} else {
if (value) {
return <IconLeftOpen size={size} className={className ?? 'icon-primary'} />;
} else {
return <IconLeftClose size={size} className={className ?? 'icon-primary'} />;
}
}
}

View File

@ -0,0 +1,8 @@
export { EditorLibraryItem } from './editor-library-item';
export { MenuRole } from './menu-role';
export { MiniSelectorOSS } from './mini-selector-oss';
export { PickSchema } from './pick-schema';
export { SelectLibraryItem } from './select-library-item';
export { SelectVersion } from './select-version';
export { ToolbarItemAccess } from './toolbar-item-access';
export { ToolbarItemCard } from './toolbar-item-card';

View File

@ -3,7 +3,7 @@ import { useAuthSuspense } from '@/features/auth';
import { useRoleStore, UserRole } from '@/features/users';
import { describeUserRole, labelUserRole } from '@/features/users/labels';
import { MiniButton } from '@/components/control';
import { Button } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
import { IconAlert } from '@/components/icons';
@ -29,11 +29,13 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
if (isAnonymous) {
return (
<MiniButton
noPadding
<Button
dense
noBorder
noOutline
titleHtml='<b>Анонимный режим</b><br />Войти в Портал'
hideTitle={accessMenu.isOpen}
className='h-full pr-2 pl-3 bg-transparent'
className='h-full pr-2'
icon={<IconAlert size='1.25rem' className='icon-red' />}
onClick={() => router.push({ path: urls.login })}
/>
@ -42,45 +44,44 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
return (
<div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'>
<MiniButton
noHover
noPadding
<Button
dense
noBorder
noOutline
title={`Режим ${labelUserRole(role)}`}
hideTitle={accessMenu.isOpen}
className='h-full pr-2 text-muted-foreground hover:text-primary cc-animate-color'
icon={<IconRole value={role} size='1.25rem' className='' />}
className='h-full pr-2'
icon={<IconRole role={role} size='1.25rem' />}
onClick={accessMenu.toggle}
/>
<Dropdown isOpen={accessMenu.isOpen} margin='mt-3'>
<DropdownButton
text={labelUserRole(UserRole.READER)}
title={describeUserRole(UserRole.READER)}
icon={<IconRole value={UserRole.READER} size='1rem' />}
icon={<IconRole role={UserRole.READER} size='1rem' />}
onClick={() => handleChangeMode(UserRole.READER)}
/>
<DropdownButton
text={labelUserRole(UserRole.EDITOR)}
title={describeUserRole(UserRole.EDITOR)}
icon={<IconRole value={UserRole.EDITOR} size='1rem' />}
icon={<IconRole role={UserRole.EDITOR} size='1rem' />}
onClick={() => handleChangeMode(UserRole.EDITOR)}
disabled={!isOwned && !isEditor}
/>
<DropdownButton
text={labelUserRole(UserRole.OWNER)}
title={describeUserRole(UserRole.OWNER)}
icon={<IconRole value={UserRole.OWNER} size='1rem' />}
icon={<IconRole role={UserRole.OWNER} size='1rem' />}
onClick={() => handleChangeMode(UserRole.OWNER)}
disabled={!isOwned}
/>
{user.is_staff ? (
<DropdownButton
text={labelUserRole(UserRole.ADMIN)}
title={describeUserRole(UserRole.ADMIN)}
icon={<IconRole value={UserRole.ADMIN} size='1rem' />}
icon={<IconRole role={UserRole.ADMIN} size='1rem' />}
onClick={() => handleChangeMode(UserRole.ADMIN)}
disabled={!user.is_staff}
/>
) : null}
</Dropdown>
</div>
);

View File

@ -5,7 +5,7 @@ import clsx from 'clsx';
import { useAuthSuspense } from '@/features/auth';
import { TextArea } from '@/components/input';
import { Label, TextArea } from '@/components/input';
import { type Styling } from '@/components/props';
import { LocationHead } from '../../models/library';
@ -35,16 +35,18 @@ export function PickLocation({
const { user } = useAuthSuspense();
return (
<div className={clsx('flex relative', className)} {...restProps}>
<div className={clsx('flex', className)} {...restProps}>
<div className='flex flex-col gap-2 min-w-28'>
<Label className='select-none' text='Корень' />
<SelectLocationHead
className='absolute right-0 top-0'
value={value.substring(0, 2) as LocationHead}
onChange={newValue => onChange(combineLocation(newValue, value.substring(3)))}
excluded={!user.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div>
<SelectLocationContext
className='absolute left-28 -top-1'
className='-mt-1 -ml-8'
dropdownHeight={dropdownHeight} //
value={value}
onChange={onChange}
@ -52,7 +54,7 @@ export function PickLocation({
<TextArea
id='dlg_location'
label='Расположение'
label='Путь'
rows={rows}
value={value.substring(3)}
onChange={event => onChange(combineLocation(value.substring(0, 2), event.target.value))}

View File

@ -38,13 +38,13 @@ export function SelectLocationContext({
<div
ref={menu.ref} //
onBlur={menu.handleBlur}
className={clsx('text-right self-start', className)}
className={clsx('relative text-right self-start', className)}
{...restProps}
>
<MiniButton
title={title}
hideTitle={menu.isOpen}
icon={<IconFolderTree size='1.25rem' className='icon-primary' />}
icon={<IconFolderTree size='1.25rem' className='icon-green' />}
onClick={() => menu.toggle()}
/>
<Dropdown isOpen={menu.isOpen} className={clsx('w-80 z-tooltip', dropdownHeight)}>

View File

@ -48,7 +48,7 @@ export function SelectLocationHead({
onClick={menu.toggle}
/>
<Dropdown isOpen={menu.isOpen} stretchLeft margin='mt-2'>
<Dropdown isOpen={menu.isOpen} margin='mt-2'>
{Object.values(LocationHead)
.filter(head => !excluded.includes(head))
.map((head, index) => {

View File

@ -62,7 +62,7 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
!dense && 'h-7 sm:h-8',
'pr-3 py-1 flex items-center gap-2',
'cc-scroll-row',
'cc-hover-bg cc-animate-color duration-fade',
'cc-hover cc-animate-color duration-fade',
'cursor-pointer',
'leading-3 sm:leading-4',
activeNode === item && 'cc-selected'
@ -73,6 +73,7 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
{item.children.size > 0 ? (
<MiniButton
noPadding
noHover
icon={
folded.includes(item) ? (
item.filesInside ? (
@ -92,7 +93,7 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
{item.filesInside ? (
<IconFolder size='1rem' className='text-foreground' />
) : (
<IconFolderEmpty size='1rem' className='text-foreground-muted' />
<IconFolderEmpty size='1rem' className='cc-controls' />
)}
</div>
)}

View File

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { BadgeHelp } from '@/features/help/components';
import { useRoleStore, UserRole } from '@/features/users';
import { MiniButton } from '@/components/control';

View File

@ -2,7 +2,7 @@
import { urls, useConceptNavigation } from '@/app';
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { BadgeHelp } from '@/features/help/components';
import { type IRSForm } from '@/features/rsform';
import { useRoleStore, UserRole } from '@/features/users';
@ -10,46 +10,29 @@ import { MiniButton } from '@/components/control';
import { IconDestroy, IconSave, IconShare } from '@/components/icons';
import { cn } from '@/components/utils';
import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { tooltipText } from '@/utils/labels';
import { prepareTooltip, sharePage } from '@/utils/utils';
import { AccessPolicy, type ILibraryItem, LibraryItemType } from '../backend/types';
import { useMutatingLibrary } from '../backend/use-mutating-library';
import { IconShowSidebar } from './icon-show-sidebar';
import { MiniSelectorOSS } from './mini-selector-oss';
interface ToolbarItemCardProps {
className?: string;
isNarrow: boolean;
onSubmit: () => void;
isMutable: boolean;
schema: ILibraryItem;
deleteSchema: () => void;
}
export function ToolbarItemCard({
className,
isNarrow,
schema,
onSubmit,
isMutable,
deleteSchema
}: ToolbarItemCardProps) {
export function ToolbarItemCard({ className, schema, onSubmit, isMutable, deleteSchema }: ToolbarItemCardProps) {
const role = useRoleStore(state => state.role);
const router = useConceptNavigation();
const { isModified } = useModificationStore();
const isProcessing = useMutatingLibrary();
const canSave = isModified && !isProcessing;
const showRSFormStats = usePreferencesStore(state => state.showRSFormStats);
const toggleShowRSFormStats = usePreferencesStore(state => state.toggleShowRSFormStats);
const showOSSStats = usePreferencesStore(state => state.showOSSStats);
const toggleShowOSSStats = usePreferencesStore(state => state.toggleShowOSSStats);
const isRSForm = schema.item_type === LibraryItemType.RSFORM;
const isOSS = schema.item_type === LibraryItemType.OSS;
const ossSelector = (() => {
if (schema.item_type !== LibraryItemType.RSFORM) {
return null;
@ -93,15 +76,6 @@ export function ToolbarItemCard({
disabled={!isMutable || isProcessing || role < UserRole.OWNER}
/>
) : null}
{(isRSForm || isOSS) && (
<MiniButton
title='Отображение статистики'
icon={
<IconShowSidebar value={isRSForm ? showRSFormStats : showOSSStats} isBottom={isNarrow} size='1.25rem' />
}
onClick={isRSForm ? toggleShowRSFormStats : toggleShowOSSStats}
/>
)}
<BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} />
</div>
);

View File

@ -3,8 +3,7 @@
import { useState } from 'react';
import { useUsers } from '@/features/users';
import { SelectUser } from '@/features/users/components/select-user';
import { TableUsers } from '@/features/users/components/table-users';
import { SelectUser, TableUsers } from '@/features/users/components';
import { MiniButton } from '@/components/control';
import { IconRemove } from '@/components/icons';
@ -49,8 +48,9 @@ export function DlgEditEditors() {
<span>Всего редакторов [{selected.length}]</span>
<MiniButton
title='Очистить список'
noHover
className='py-0 align-middle'
icon={<IconRemove size='1.25rem' className='cc-remove' />}
icon={<IconRemove size='1.5rem' className='cc-remove' />}
onClick={() => setSelected([])}
disabled={selected.length === 0}
/>

View File

@ -63,6 +63,7 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
<MiniButton
title='Удалить версию'
className='align-middle'
noHover
noPadding
icon={<IconRemove size='1.25rem' className='cc-remove' />}
onClick={event => handleDeleteVersion(event, props.row.original.id)}

View File

@ -1,4 +1,4 @@
import { RequireAuth } from '@/features/auth/components/require-auth';
import { RequireAuth } from '@/features/auth/components';
import { FormCreateItem } from './form-create-item';

View File

@ -58,7 +58,7 @@ export function LibraryPage() {
<MiniButton
title='Выгрузить в формате CSV'
className='absolute z-tooltip -top-8 right-6 hidden sm:block'
icon={<IconCSV size='1.25rem' className='text-muted-foreground hover:text-constructive' />}
icon={<IconCSV size='1.25rem' className='icon-green' />}
onClick={handleDownloadCSV}
/>

View File

@ -2,7 +2,7 @@
import clsx from 'clsx';
import { SelectUser } from '@/features/users/components/select-user';
import { SelectUser } from '@/features/users/components';
import { MiniButton, SelectorButton } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
@ -156,21 +156,28 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
(head ? describeLocationHead(head) : 'Выберите каталог') + '<br/><kbd>Ctrl + клик</kbd> - Проводник'
}
hideTitle={headMenu.isOpen}
icon={head ? <IconLocationHead value={head} size='1.25rem' /> : <IconFolderSearch size='1.25rem' />}
icon={
head ? (
<IconLocationHead value={head} size='1.25rem' />
) : (
<IconFolderSearch size='1.25rem' className='cc-controls' />
)
}
onClick={handleFolderClick}
text={head ?? '//'}
/>
<Dropdown isOpen={headMenu.isOpen} stretchLeft>
<DropdownButton
text='проводник...'
title='Переключение в режим Проводник'
icon={<IconFolderTree size='1rem' className='icon-primary' />}
icon={<IconFolderTree size='1rem' className='cc-controls' />}
onClick={handleToggleFolder}
/>
<DropdownButton
text='отображать все'
title='Очистить фильтр по расположению'
icon={<IconFolder size='1rem' className='icon-primary' />}
icon={<IconFolder size='1rem' className='cc-controls' />}
onClick={() => handleChange(null)}
/>
{Object.values(LocationHead).map((head, index) => {
@ -193,7 +200,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
placeholder='Путь'
noIcon
noBorder
className='w-18 sm:w-20 grow ml-1'
className='w-18 sm:w-20 grow'
query={path}
onChangeQuery={setPath}
/>

View File

@ -39,9 +39,10 @@ export function useLibraryColumns() {
titleHtml='Переключение в режим Проводник'
aria-label='Переключатель режима Проводник'
noPadding
className='ml-2 max-h-4 -translate-y-0.5'
noHover
className='pl-2 max-h-4 -translate-y-0.5'
onClick={handleToggleFolder}
icon={<IconFolderTree size='1.25rem' className='text-primary' />}
icon={<IconFolderTree size='1.25rem' className='cc-controls' />}
/>
),
size: 50,

View File

@ -3,7 +3,7 @@ import clsx from 'clsx';
import { useAuthSuspense } from '@/features/auth';
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { BadgeHelp } from '@/features/help/components';
import { MiniButton } from '@/components/control';
import { IconFolderEdit, IconFolderTree } from '@/components/icons';
@ -86,8 +86,8 @@ export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocati
/>
) : null}
<MiniButton
title='Переключение в режим Таблица'
icon={<IconFolderTree size='1.25rem' className='text-primary' />}
title='Переключение в режим Поиск'
icon={<IconFolderTree size='1.25rem' className='icon-green' />}
onClick={toggleFolderMode}
/>
</div>

View File

@ -50,7 +50,7 @@ export const ossApi = {
axiosPatch({
endpoint: `/api/oss/${itemID}/update-layout`,
request: {
data: { data: data },
data: data,
successMessage: isSilent ? undefined : infoMsg.changesSaved
}
}),

View File

@ -90,7 +90,7 @@ export class OssLoader {
this.graph.topologicalOrder().forEach(operationID => {
const operation = this.operationByID.get(operationID)!;
const schema = this.items.find(item => item.id === operation.result);
const position = this.oss.layout.find(item => item.nodeID === operation.nodeID);
const position = this.oss.layout.operations.find(item => item.id === operationID);
operation.x = position?.x ?? 0;
operation.y = position?.y ?? 0;
operation.is_consolidation = this.inferConsolidation(operationID);
@ -104,7 +104,7 @@ export class OssLoader {
private inferBlockAttributes() {
this.oss.blocks.forEach(block => {
const geometry = this.oss.layout.find(item => item.nodeID === block.nodeID);
const geometry = this.oss.layout.blocks.find(item => item.id === block.id);
block.x = geometry?.x ?? 0;
block.y = geometry?.y ?? 0;
block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH;

View File

@ -72,8 +72,11 @@ export type IRelocateConstituentsDTO = z.infer<typeof schemaRelocateConstituents
/** Represents {@link IConstituenta} reference. */
export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>;
/** Represents {@link IOperationSchema} node position. */
export type INodePosition = z.infer<typeof schemaNodePosition>;
/** Represents {@link IOperation} position. */
export type IOperationPosition = z.infer<typeof schemaOperationPosition>;
/** Represents {@link IBlock} position. */
export type IBlockPosition = z.infer<typeof schemaBlockPosition>;
// ====== Schemas ======
export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]);
@ -105,15 +108,24 @@ export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({
substitution_term: z.string()
});
export const schemaNodePosition = z.strictObject({
nodeID: z.string(),
export const schemaOperationPosition = z.strictObject({
id: z.number(),
x: z.number(),
y: z.number()
});
export const schemaBlockPosition = z.strictObject({
id: z.number(),
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number()
});
export const schemaOssLayout = z.array(schemaNodePosition);
export const schemaOssLayout = z.strictObject({
operations: z.array(schemaOperationPosition),
blocks: z.array(schemaBlockPosition)
});
export const schemaOperationSchema = schemaLibraryItem.extend({
editors: z.number().array(),
@ -176,8 +188,6 @@ export const schemaCreateOperation = z.strictObject({
}),
position_x: z.number(),
position_y: z.number(),
width: z.number(),
height: z.number(),
arguments: z.array(z.number()),
create_schema: z.boolean()
});

View File

@ -98,18 +98,21 @@ export function PickContents({
<div className='flex w-fit'>
<MiniButton
title='Удалить'
noHover
className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original)}
/>
<MiniButton
title='Переместить выше'
noHover
className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original)}
/>
<MiniButton
title='Переместить ниже'
noHover
className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original)}

View File

@ -82,18 +82,21 @@ export function PickMultiOperation({ rows, items, value, onChange, className, ..
<div className='flex w-fit'>
<MiniButton
title='Удалить'
noHover
className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original.id)}
/>
<MiniButton
title='Переместить выше'
noHover
className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original.id)}
/>
<MiniButton
title='Переместить ниже'
noHover
className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original.id)}

View File

@ -5,7 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-library';
import { PickSchema } from '@/features/library/components/pick-schema';
import { PickSchema } from '@/features/library/components';
import { MiniButton } from '@/components/control';
import { IconReset } from '@/components/icons';
@ -61,6 +61,7 @@ export function DlgChangeInputSchema() {
<Label text='Загружаемая концептуальная схема' />
<MiniButton
title='Сбросить выбор схемы'
noHover
noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setValue('input', null)}

View File

@ -84,9 +84,14 @@ export function DlgCreateBlock() {
className='w-160 px-6 h-110'
helpTopic={HelpTopic.CC_OSS}
>
<Tabs className='grid' selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none'>
<TabLabel title='Основные атрибуты блока' label='Паспорт' />
<Tabs
selectedTabClassName='cc-selected'
className='grid'
selectedIndex={activeTab}
onSelect={index => setActiveTab(index as TabID)}
>
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'>
<TabLabel title='Основные атрибуты блока' label='Карточка' />
<TabLabel
title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`}
label={`Содержимое${children_operations.length + children_blocks.length > 0 ? '*' : ''}`}

View File

@ -13,7 +13,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICreateOperationDTO, OperationType, schemaCreateOperation } from '../../backend/types';
import { useCreateOperation } from '../../backend/use-create-operation';
import { describeOperationType, labelOperationType } from '../../labels';
import { type LayoutManager, OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../models/oss-layout-api';
import { type LayoutManager } from '../../models/oss-layout-api';
import { TabInputOperation } from './tab-input-operation';
import { TabSynthesisOperation } from './tab-synthesis-operation';
@ -54,8 +54,6 @@ export function DlgCreateOperation() {
position_x: defaultX,
position_y: defaultY,
arguments: initialInputs,
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT,
create_schema: false,
layout: manager.layout
},
@ -100,11 +98,12 @@ export function DlgCreateOperation() {
helpTopic={HelpTopic.CC_OSS}
>
<Tabs
selectedTabClassName='cc-selected'
className='grid'
selectedIndex={activeTab}
onSelect={(index, last) => handleSelectTab(index as TabID, last as TabID)}
>
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none'>
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none bg-secondary'>
<TabLabel
title={describeOperationType(OperationType.INPUT)}
label={labelOperationType(OperationType.INPUT)}

View File

@ -4,7 +4,7 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-library';
import { PickSchema } from '@/features/library/components/pick-schema';
import { PickSchema } from '@/features/library/components';
import { MiniButton } from '@/components/control';
import { IconReset } from '@/components/icons';
@ -97,6 +97,7 @@ export function TabInputOperation() {
<Label text='Загружаемая концептуальная схема' />
<MiniButton
title='Сбросить выбор схемы'
noHover
noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setValue('item_data.result', null)}

View File

@ -43,7 +43,7 @@ export function DlgEditBlock() {
function onSubmit(data: IUpdateBlockDTO) {
if (data.item_data.parent !== target.parent) {
manager.onChangeParent(target.nodeID, data.item_data.parent === null ? null : `b${data.item_data.parent}`);
manager.onBlockChangeParent(data.target, data.item_data.parent);
data.layout = manager.layout;
}
return updateBlock({ itemID: manager.oss.id, data });

View File

@ -59,7 +59,7 @@ export function DlgEditOperation() {
function onSubmit(data: IUpdateOperationDTO) {
if (data.item_data.parent !== target.parent) {
manager.onChangeParent(target.nodeID, data.item_data.parent === null ? null : `b${data.item_data.parent}`);
manager.onOperationChangeParent(data.target, data.item_data.parent);
data.layout = manager.layout;
}
return updateOperation({ itemID: manager.oss.id, data });
@ -75,11 +75,16 @@ export function DlgEditOperation() {
helpTopic={HelpTopic.UI_SUBSTITUTIONS}
hideHelpWhen={() => activeTab !== TabID.SUBSTITUTION}
>
<Tabs className='grid' selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none'>
<Tabs
selectedTabClassName='cc-selected'
className='grid'
selectedIndex={activeTab}
onSelect={index => setActiveTab(index as TabID)}
>
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none bg-secondary'>
<TabLabel
title='Текстовые поля' //
label='Паспорт'
label='Карточка'
className='w-32'
/>
<TabLabel

View File

@ -3,7 +3,7 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { useRSForms } from '@/features/rsform/backend/use-rsforms';
import { PickSubstitutions } from '@/features/rsform/components/pick-substitutions';
import { PickSubstitutions } from '@/features/rsform/components';
import { TextArea } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';

View File

@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { HelpTopic } from '@/features/help';
import { type ILibraryItem } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-library';
import { SelectLibraryItem } from '@/features/library/components/select-library-item';
import { SelectLibraryItem } from '@/features/library/components';
import { useRSForm } from '@/features/rsform/backend/use-rsform';
import { PickMultiConstituenta } from '@/features/rsform/components/pick-multi-constituenta';
import { PickMultiConstituenta } from '@/features/rsform/components';
import { MiniButton } from '@/components/control';
import { Loader } from '@/components/loader';

View File

@ -1,4 +1,10 @@
import { type ICreateBlockDTO, type ICreateOperationDTO, type INodePosition, type IOssLayout } from '../backend/types';
import {
type IBlockPosition,
type ICreateBlockDTO,
type ICreateOperationDTO,
type IOperationPosition,
type IOssLayout
} from '../backend/types';
import { type IOperationSchema } from './oss';
import { type Position2D, type Rectangle2D } from './oss-layout';
@ -6,8 +12,8 @@ import { type Position2D, type Rectangle2D } from './oss-layout';
export const GRID_SIZE = 10; // pixels - size of OSS grid
const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes
export const OPERATION_NODE_WIDTH = 150;
export const OPERATION_NODE_HEIGHT = 40;
const OPERATION_NODE_WIDTH = 150;
const OPERATION_NODE_HEIGHT = 40;
/** Layout manipulations for {@link IOperationSchema}. */
export class LayoutManager {
@ -24,40 +30,52 @@ export class LayoutManager {
}
/** Calculate insert position for a new {@link IOperation} */
newOperationPosition(data: ICreateOperationDTO): Rectangle2D {
const result = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null;
const operations = this.layout.filter(pos => pos.nodeID.startsWith('o'));
newOperationPosition(data: ICreateOperationDTO): Position2D {
let result = { x: data.position_x, y: data.position_y };
const operations = this.layout.operations;
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
if (operations.length === 0) {
return result;
}
if (data.arguments.length !== 0) {
const pos = calculatePositionFromArgs(
operations.filter(node => data.arguments.includes(Number(node.nodeID.slice(1))))
);
result.x = pos.x;
result.y = pos.y;
result = calculatePositionFromArgs(data.arguments, operations);
} else if (parentNode) {
result.x = parentNode.x + MIN_DISTANCE;
result.y = parentNode.y + MIN_DISTANCE;
} else {
const pos = this.calculatePositionForFreeOperation(result);
result.x = pos.x;
result.y = pos.y;
result = this.calculatePositionForFreeOperation(result);
}
preventOverlap(result, operations);
this.extendParentBounds(parentNode, result);
result = preventOverlap(
{ ...result, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT },
operations.map(node => ({ ...node, width: OPERATION_NODE_WIDTH, height: OPERATION_NODE_HEIGHT }))
);
return result;
if (parentNode) {
const borderX = result.x + OPERATION_NODE_WIDTH + MIN_DISTANCE;
const borderY = result.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE;
if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
}
if (borderY > parentNode.y + parentNode.height) {
parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return { x: result.x, y: result.y };
}
/** Calculate insert position for a new {@link IBlock} */
newBlockPosition(data: ICreateBlockDTO): Rectangle2D {
const block_nodes = data.children_blocks
.map(id => this.layout.find(block => block.nodeID === `b${id}`))
.map(id => this.layout.blocks.find(block => block.id === id))
.filter(node => !!node);
const operation_nodes = data.children_operations
.map(id => this.layout.find(operation => operation.nodeID === `o${id}`))
.map(id => this.layout.operations.find(operation => operation.id === id))
.filter(node => !!node);
const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null;
const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
let result: Rectangle2D = { x: data.position_x, y: data.position_y, width: data.width, height: data.height };
@ -80,78 +98,61 @@ export class LayoutManager {
if (block_nodes.length === 0 && operation_nodes.length === 0) {
if (parentNode) {
const siblings = this.oss.blocks
.filter(block => block.parent === data.item_data.parent)
.map(block => block.nodeID);
const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id);
if (siblings.length > 0) {
preventOverlap(
result = preventOverlap(
result,
this.layout.filter(node => siblings.includes(node.nodeID))
this.layout.blocks.filter(block => siblings.includes(block.id))
);
}
} else {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id);
if (rootBlocks.length > 0) {
preventOverlap(
result = preventOverlap(
result,
this.layout.filter(node => rootBlocks.includes(node.nodeID))
this.layout.blocks.filter(block => rootBlocks.includes(block.id))
);
}
}
}
this.extendParentBounds(parentNode, result);
if (parentNode) {
const borderX = result.x + result.width + MIN_DISTANCE;
const borderY = result.y + result.height + MIN_DISTANCE;
if (borderX > parentNode.x + parentNode.width) {
parentNode.width = borderX - parentNode.x;
}
if (borderY > parentNode.y + parentNode.height) {
parentNode.height = borderY - parentNode.y;
}
// TODO: trigger cascading updates
}
return result;
}
/** Update layout when parent changes */
onChangeParent(targetID: string, newParent: string | null) {
const targetNode = this.layout.find(pos => pos.nodeID === targetID);
if (!targetNode) {
return;
onOperationChangeParent(targetID: number, newParent: number | null) {
console.error('not implemented', targetID, newParent);
}
const parentNode = this.layout.find(pos => pos.nodeID === newParent) ?? null;
const offset = this.calculateOffsetForParentChange(targetNode, parentNode);
if (offset.x === 0 && offset.y === 0) {
return;
}
targetNode.x += offset.x;
targetNode.y += offset.y;
const children = this.oss.hierarchy.expandAllOutputs([targetID]);
const childrenPositions = this.layout.filter(pos => children.includes(pos.nodeID));
for (const child of childrenPositions) {
child.x += offset.x;
child.y += offset.y;
}
this.extendParentBounds(parentNode, targetNode);
}
private extendParentBounds(parent: INodePosition | null, child: Rectangle2D) {
if (!parent) {
return;
}
const borderX = child.x + child.width + MIN_DISTANCE;
const borderY = child.y + child.height + MIN_DISTANCE;
parent.width = Math.max(parent.width, borderX - parent.x);
parent.height = Math.max(parent.height, borderY - parent.y);
// TODO: cascade update
/** Update layout when parent changes */
onBlockChangeParent(targetID: number, newParent: number | null) {
console.error('not implemented', targetID, newParent);
}
private calculatePositionForFreeOperation(initial: Position2D): Position2D {
if (this.oss.operations.length === 0) {
const operations = this.layout.operations;
if (operations.length === 0) {
return initial;
}
const freeInputs = this.oss.operations
.filter(operation => operation.arguments.length === 0 && operation.parent === null)
.map(operation => operation.nodeID);
let inputsPositions = this.layout.filter(pos => freeInputs.includes(pos.nodeID));
.map(operation => operation.id);
let inputsPositions = operations.filter(pos => freeInputs.includes(pos.id));
if (inputsPositions.length === 0) {
inputsPositions = this.layout.filter(pos => pos.nodeID.startsWith('o'));
inputsPositions = operations;
}
const maxX = Math.max(...inputsPositions.map(node => node.x));
const minY = Math.min(...inputsPositions.map(node => node.y));
@ -162,8 +163,8 @@ export class LayoutManager {
}
private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D {
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.nodeID);
const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID));
const rootBlocks = this.oss.blocks.filter(block => block.parent === null).map(block => block.id);
const blocksPositions = this.layout.blocks.filter(pos => rootBlocks.includes(pos.id));
if (blocksPositions.length === 0) {
return initial;
}
@ -171,23 +172,6 @@ export class LayoutManager {
const minY = Math.min(...blocksPositions.map(node => node.y));
return { ...initial, x: maxX + MIN_DISTANCE, y: minY };
}
private calculateOffsetForParentChange(target: INodePosition, parent: INodePosition | null): Position2D {
const newPosition = { ...target };
if (parent === null) {
const rootElements = this.oss.hierarchy.rootNodes();
const positions = this.layout.filter(pos => rootElements.includes(pos.nodeID));
preventOverlap(newPosition, positions);
} else if (!rectanglesOverlap(target, parent)) {
newPosition.x = parent.x + MIN_DISTANCE;
newPosition.y = parent.y + MIN_DISTANCE;
const siblings = this.oss.hierarchy.at(parent.nodeID)?.outputs ?? [];
const siblingsPositions = this.layout.filter(pos => siblings.includes(pos.nodeID));
preventOverlap(newPosition, siblingsPositions);
}
return { x: newPosition.x - target.x, y: newPosition.y - target.y };
}
}
// ======= Internals =======
@ -200,25 +184,38 @@ function rectanglesOverlap(a: Rectangle2D, b: Rectangle2D): boolean {
);
}
function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]) {
function getOverlapAmount(a: Rectangle2D, b: Rectangle2D): Position2D {
const xOverlap = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
const yOverlap = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
return { x: xOverlap, y: yOverlap };
}
function preventOverlap(target: Rectangle2D, fixedRectangles: Rectangle2D[]): Rectangle2D {
let hasOverlap: boolean;
do {
hasOverlap = false;
for (const fixed of fixedRectangles) {
if (rectanglesOverlap(target, fixed)) {
hasOverlap = true;
target.x += MIN_DISTANCE;
target.y += MIN_DISTANCE;
const overlap = getOverlapAmount(target, fixed);
if (overlap.x >= overlap.y) {
target.x += overlap.x + MIN_DISTANCE;
} else {
target.y += overlap.y + MIN_DISTANCE;
}
break;
}
}
} while (hasOverlap);
return target;
}
function calculatePositionFromArgs(args: INodePosition[]): Position2D {
const maxY = Math.max(...args.map(node => node.y));
const minX = Math.min(...args.map(node => node.x));
const maxX = Math.max(...args.map(node => node.x));
function calculatePositionFromArgs(args: number[], operations: IOperationPosition[]): Position2D {
const argNodes = operations.filter(pos => args.includes(pos.id));
const maxY = Math.max(...argNodes.map(node => node.y));
const minX = Math.min(...argNodes.map(node => node.x));
const maxX = Math.max(...argNodes.map(node => node.x));
return {
x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE,
y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE
@ -227,23 +224,42 @@ function calculatePositionFromArgs(args: INodePosition[]): Position2D {
function calculatePositionFromChildren(
initial: Rectangle2D,
operations: INodePosition[],
blocks: INodePosition[]
operations: IOperationPosition[],
blocks: IBlockPosition[]
): Rectangle2D {
const allNodes = [...blocks, ...operations];
if (allNodes.length === 0) {
return initial;
let left = undefined;
let top = undefined;
let right = undefined;
let bottom = undefined;
for (const block of blocks) {
left = left === undefined ? block.x - MIN_DISTANCE : Math.min(left, block.x - MIN_DISTANCE);
top = top === undefined ? block.y - MIN_DISTANCE : Math.min(top, block.y - MIN_DISTANCE);
right =
right === undefined
? Math.max(left + initial.width, block.x + block.width + MIN_DISTANCE)
: Math.max(right, block.x + block.width + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, block.y + block.height + MIN_DISTANCE)
: Math.max(bottom, block.y + block.height + MIN_DISTANCE);
}
const left = Math.min(...allNodes.map(n => n.x)) - MIN_DISTANCE;
const top = Math.min(...allNodes.map(n => n.y)) - MIN_DISTANCE;
const right = Math.max(...allNodes.map(n => n.x + n.width)) + MIN_DISTANCE;
const bottom = Math.max(...allNodes.map(n => n.y + n.height)) + MIN_DISTANCE;
for (const operation of operations) {
left = left === undefined ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE);
top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE);
right =
right === undefined
? Math.max(left + initial.width, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE)
: Math.max(right, operation.x + OPERATION_NODE_WIDTH + MIN_DISTANCE);
bottom = !bottom
? Math.max(top + initial.height, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE)
: Math.max(bottom, operation.y + OPERATION_NODE_HEIGHT + MIN_DISTANCE);
}
return {
x: left,
y: top,
width: right - left,
height: bottom - top
x: left ?? initial.x,
y: top ?? initial.y,
width: right !== undefined && left !== undefined ? right - left : initial.width,
height: bottom !== undefined && top !== undefined ? bottom - top : initial.height
};
}

View File

@ -2,27 +2,19 @@
import clsx from 'clsx';
import { EditorLibraryItem } from '@/features/library/components/editor-library-item';
import { ToolbarItemCard } from '@/features/library/components/toolbar-item-card';
import { EditorLibraryItem, ToolbarItemCard } from '@/features/library/components';
import { useWindowSize } from '@/hooks/use-window-size';
import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { globalIDs } from '@/utils/constants';
import { OssStats } from '../../../components/oss-stats';
import { useOssEdit } from '../oss-edit-context';
import { FormOSS } from './form-oss';
const SIDELIST_LAYOUT_THRESHOLD = 768; // px
import { OssStats } from './oss-stats';
export function EditorOssCard() {
const { schema, isMutable, deleteSchema } = useOssEdit();
const { isModified } = useModificationStore();
const showOSSStats = usePreferencesStore(state => state.showOSSStats);
const windowSize = useWindowSize();
const isNarrow = !!windowSize.width && windowSize.width <= SIDELIST_LAYOUT_THRESHOLD;
function initiateSubmit() {
const element = document.getElementById(globalIDs.library_item_editor) as HTMLFormElement;
@ -41,36 +33,25 @@ export function EditorOssCard() {
}
return (
<div
onKeyDown={handleInput}
className={clsx(
'relative md:w-fit md:max-w-fit max-w-128',
'flex px-6 pt-8',
isNarrow && 'flex-col md:items-center'
)}
>
<>
<ToolbarItemCard
className='cc-tab-tools'
onSubmit={initiateSubmit}
schema={schema}
isMutable={isMutable}
deleteSchema={deleteSchema}
isNarrow={isNarrow}
/>
<div className='cc-column px-3 mx-0 md:mx-auto'>
<div
onKeyDown={handleInput}
className={clsx('md:max-w-fit max-w-128 min-w-fit', 'flex flex-row flex-wrap pt-8 px-6 justify-center')}
>
<div className='cc-column px-3'>
<FormOSS key={schema.id} />
<EditorLibraryItem schema={schema} isAttachedToOSS={false} />
</div>
<OssStats
className={clsx(
'w-80 md:w-56 mt-3 md:mt-8 mx-auto md:ml-5 md:mr-0',
'cc-animate-sidebar',
showOSSStats ? 'max-w-full' : 'opacity-0 max-w-0'
)}
stats={schema.stats}
/>
<OssStats className='mt-3 md:mt-8 md:ml-5 w-80 md:w-56 mx-auto h-min' stats={schema.stats} />
</div>
</>
);
}

View File

@ -7,7 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { type IUpdateLibraryItemDTO, LibraryItemType, schemaUpdateLibraryItem } from '@/features/library';
import { useUpdateItem } from '@/features/library/backend/use-update-item';
import { ToolbarItemAccess } from '@/features/library/components/toolbar-item-access';
import { ToolbarItemAccess } from '@/features/library/components';
import { SubmitButton } from '@/components/control';
import { IconSave } from '@/components/icons';

View File

@ -9,7 +9,7 @@ import {
import { cn } from '@/components/utils';
import { ValueStats } from '@/components/view';
import { type IOperationSchemaStats } from '../models/oss';
import { type IOperationSchemaStats } from '../../../models/oss';
interface OssStatsProps {
className?: string;
@ -18,43 +18,48 @@ interface OssStatsProps {
export function OssStats({ className, stats }: OssStatsProps) {
return (
<aside className={cn('grid grid-cols-4 gap-1 justify-items-end h-min', className)}>
<div className={cn('grid grid-cols-4 gap-1 justify-items-end', className)}>
<div id='count_operations' className='w-fit flex gap-3 hover:cursor-default '>
<span>Всего</span>
<span>{stats.count_all}</span>
</div>
<ValueStats id='count_block' title='Блоки' icon={<IconConceptBlock size='1.25rem' />} value={stats.count_block} />
<ValueStats
id='count_block'
title='Блоки'
icon={<IconConceptBlock size='1.25rem' className='text-primary' />}
value={stats.count_block}
/>
<ValueStats
id='count_inputs'
title='Загрузка'
icon={<IconDownload size='1.25rem' />}
icon={<IconDownload size='1.25rem' className='text-primary' />}
value={stats.count_inputs}
/>
<ValueStats
id='count_synthesis'
title='Синтез'
icon={<IconSynthesis size='1.25rem' />}
icon={<IconSynthesis size='1.25rem' className='text-primary' />}
value={stats.count_synthesis}
/>
<ValueStats
id='count_schemas'
title='Прикрепленные схемы'
icon={<IconRSForm size='1.25rem' />}
icon={<IconRSForm size='1.25rem' className='text-primary' />}
value={stats.count_schemas}
/>
<ValueStats
id='count_owned'
title='Собственные'
icon={<IconRSFormOwned size='1.25rem' />}
icon={<IconRSFormOwned size='1.25rem' className='text-primary' />}
value={stats.count_owned}
/>
<ValueStats
id='count_imported'
title='Внешние'
icon={<IconRSFormImported size='1.25rem' />}
icon={<IconRSFormImported size='1.25rem' className='text-primary' />}
value={stats.count_schemas - stats.count_owned}
/>
</aside>
</div>
);
}

View File

@ -19,13 +19,18 @@ export function useContextMenu() {
const setHoverOperation = useOperationTooltipStore(state => state.setHoverItem);
const { addSelectedNodes } = useStoreApi().getState();
function openContextMenu(node: OssNode, clientX: number, clientY: number) {
function handleContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
addSelectedNodes([node.id]);
setMenuProps({
item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null,
cursorX: clientX,
cursorY: clientY
cursorX: event.clientX,
cursorY: event.clientY
});
setIsOpen(true);
setHoverOperation(null);
}
@ -37,7 +42,7 @@ export function useContextMenu() {
return {
isOpen,
menuProps,
openContextMenu,
handleContextMenu,
hideContextMenu
};
}

View File

@ -38,6 +38,9 @@ export function NodeCore({ node }: NodeCoreProps) {
'relative flex items-center justify-center p-[2px]',
isChild && 'border-accent-orange'
)}
data-tooltip-id={globalIDs.operation_tooltip}
data-tooltip-hidden={node.dragging}
onMouseEnter={() => setHover(node.data.operation)}
>
<div className='absolute z-pop top-0 right-0 flex flex-col gap-[4px] p-[2px]'>
<Indicator
@ -76,12 +79,9 @@ export function NodeCore({ node }: NodeCoreProps) {
<div
className={clsx(
'text-center line-clamp-2 px-[4px] mr-[12px]',
longLabel ? 'text-[12px]/[16px]' : 'text-[14px]/[20px]'
'text-center line-clamp-2 pl-[4px]',
longLabel ? 'text-[12px]/[16px] pr-[10px]' : 'text-[14px]/[20px] pr-[4px]'
)}
data-tooltip-id={globalIDs.operation_tooltip}
data-tooltip-hidden={node.dragging}
onMouseEnter={() => setHover(node.data.operation)}
>
{node.data.label}
</div>

View File

@ -6,7 +6,6 @@ import clsx from 'clsx';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels';
@ -24,7 +23,6 @@ import { useContextMenu } from './context-menu/use-context-menu';
import { OssNodeTypes } from './graph/oss-node-types';
import { CoordinateDisplay } from './coordinate-display';
import { useOssFlow } from './oss-flow-context';
import { SidePanel } from './side-panel';
import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useDragging } from './use-dragging';
import { useGetLayout } from './use-get-layout';
@ -54,7 +52,6 @@ export function OssFlow() {
const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const showPanel = usePreferencesStore(state => state.showOssSidePanel);
const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout();
@ -67,7 +64,7 @@ export function OssFlow() {
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
const showEditBlock = useDialogsStore(state => state.showEditBlock);
const { isOpen: isContextMenuOpen, menuProps, openContextMenu, hideContextMenu } = useContextMenu();
const { isOpen: isContextMenuOpen, menuProps, handleContextMenu, hideContextMenu } = useContextMenu();
const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu });
function handleSavePositions() {
@ -138,8 +135,8 @@ export function OssFlow() {
});
}
} else {
if (node.data.operation) {
navigateOperationSchema(node.data.operation.id);
if (node.data.operation?.result) {
navigateOperationSchema(Number(node.id));
}
}
}
@ -186,29 +183,19 @@ export function OssFlow() {
setMouseCoords(targetPosition);
}
function handleNodeContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
openContextMenu(node, event.clientX, event.clientY);
}
return (
<div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}>
{showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null}
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<ToolbarOssGraph
className='absolute z-pop top-8 right-1/2 translate-x-1/2'
onCreateOperation={handleCreateOperation}
onCreateBlock={handleCreateBlock}
onDelete={handleDeleteSelected}
onResetPositions={resetGraph}
openContextMenu={openContextMenu}
isContextMenuOpen={isContextMenuOpen}
hideContextMenu={hideContextMenu}
/>
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<DiagramFlow
{...flowOptions}
className={clsx(!containMovement && 'cursor-relocate')}
@ -222,22 +209,12 @@ export function OssFlow() {
showGrid={showGrid}
onClick={hideContextMenu}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onNodeContextMenu={handleContextMenu}
onContextMenu={hideContextMenu}
onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop}
/>
<SidePanel
className={clsx(
'absolute right-0 top-0 z-sticky w-84 min-h-80',
'cc-animate-panel cc-shadow-border',
showPanel ? 'translate-x-0' : 'opacity-0 translate-x-full pointer-events-none'
)}
isMounted={showPanel}
selectedItems={selectedItems}
/>
</div>
);
}

View File

@ -1 +0,0 @@
export { SidePanel } from './side-panel';

View File

@ -1,72 +0,0 @@
import { Suspense } from 'react';
import clsx from 'clsx';
import { useDebounce } from 'use-debounce';
import { MiniButton } from '@/components/control';
import { IconClose } from '@/components/icons';
import { Loader } from '@/components/loader';
import { cn } from '@/components/utils';
import { useMainHeight } from '@/stores/app-layout';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants';
import { type IOssItem, NodeType } from '../../../../models/oss';
import { ViewSchema } from './view-schema';
interface SidePanelProps {
selectedItems: IOssItem[];
className?: string;
isMounted: boolean;
}
export function SidePanel({ selectedItems, isMounted, className }: SidePanelProps) {
const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedSchema = selectedOperation?.result ?? null;
const debouncedMounted = useDebounce(isMounted, PARAMETER.moveDuration);
const closePanel = usePreferencesStore(state => state.toggleShowOssSidePanel);
const sidePanelHeight = useMainHeight();
return (
<div
className={cn(
'relative flex flex-col py-2 h-full overflow-hidden',
'border-l rounded-none rounded-l-sm bg-background',
className
)}
style={{ height: sidePanelHeight }}
>
<MiniButton
titleHtml='Закрыть панель'
aria-label='Закрыть'
noPadding
icon={<IconClose size='1.25rem' />}
className='absolute z-pop top-2 right-1'
onClick={closePanel}
/>
<div
className={clsx(
'mt-0 mb-1',
'font-medium text-sm select-none self-center',
'transition-transform',
selectedSchema && 'translate-x-16'
)}
>
Содержание КС
</div>
{!selectedOperation ? (
<div className='text-center text-sm cc-fade-in'>Выделите операцию для просмотра</div>
) : !selectedSchema ? (
<div className='text-center text-sm cc-fade-in'>Отсутствует концептуальная схема для выбранной операции</div>
) : debouncedMounted ? (
<Suspense fallback={<Loader />}>
<ViewSchema schemaID={selectedSchema} />
</Suspense>
) : null}
</div>
);
}

View File

@ -1,195 +0,0 @@
import { urls, useConceptNavigation } from '@/app';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { CstType, type IConstituentaBasicsDTO, type ICreateConstituentaDTO } from '@/features/rsform/backend/types';
import { useCreateConstituenta } from '@/features/rsform/backend/use-create-constituenta';
import { useMoveConstituents } from '@/features/rsform/backend/use-move-constituents';
import { useMutatingRSForm } from '@/features/rsform/backend/use-mutating-rsform';
import { generateAlias } from '@/features/rsform/models/rsform-api';
import { useCstSearchStore } from '@/features/rsform/stores/cst-search';
import { MiniButton } from '@/components/control';
import { IconClone, IconDestroy, IconMoveDown, IconMoveUp, IconNewItem, IconRSForm } from '@/components/icons';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER, prefixes } from '@/utils/constants';
import { type RO } from '@/utils/meta';
interface ToolbarConstituentsProps {
schema: IRSForm;
activeCst: IConstituenta | null;
setActive: (cstID: number) => void;
resetActive: () => void;
className?: string;
}
export function ToolbarConstituents({
schema,
activeCst,
setActive,
resetActive,
className
}: ToolbarConstituentsProps) {
const router = useConceptNavigation();
const isProcessing = useMutatingRSForm();
const searchText = useCstSearchStore(state => state.query);
const hasSearch = searchText.length > 0;
const showCreateCst = useDialogsStore(state => state.showCreateCst);
const showDeleteCst = useDialogsStore(state => state.showDeleteCst);
const { moveConstituents } = useMoveConstituents();
const { createConstituenta } = useCreateConstituenta();
function navigateRSForm() {
router.push({ path: urls.schema(schema.id) });
}
function onCreateCst(newCst: RO<IConstituentaBasicsDTO>) {
setActive(newCst.id);
setTimeout(() => {
const element = document.getElementById(`${prefixes.cst_list}${newCst.id}`);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end'
});
}
}, PARAMETER.refreshTimeout);
}
function createCst() {
const targetType = activeCst?.cst_type ?? CstType.BASE;
const data: ICreateConstituentaDTO = {
insert_after: activeCst?.id ?? null,
cst_type: targetType,
alias: generateAlias(targetType, schema),
term_raw: '',
definition_formal: '',
definition_raw: '',
convention: '',
term_forms: []
};
showCreateCst({ schema: schema, onCreate: onCreateCst, initial: data });
}
function cloneCst() {
if (!activeCst) {
return;
}
void createConstituenta({
itemID: schema.id,
data: {
insert_after: activeCst.id,
cst_type: activeCst.cst_type,
alias: generateAlias(activeCst.cst_type, schema),
term_raw: activeCst.term_raw,
definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw,
convention: activeCst.convention,
term_forms: activeCst.term_forms
}
}).then(onCreateCst);
}
function promptDeleteCst() {
if (!activeCst) {
return;
}
showDeleteCst({
schema: schema,
selected: [activeCst.id],
afterDelete: resetActive
});
}
function moveUp() {
if (!activeCst) {
return;
}
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (activeCst.id !== cst.id) {
return prev;
} else if (prev === -1) {
return index;
}
return Math.min(prev, index);
}, -1);
const target = Math.max(0, currentIndex - 1);
void moveConstituents({
itemID: schema.id,
data: {
items: [activeCst.id],
move_to: target
}
});
}
function moveDown() {
if (!activeCst) {
return;
}
let count = 0;
const currentIndex = schema.items.reduce((prev, cst, index) => {
if (activeCst.id !== cst.id) {
return prev;
} else {
count += 1;
if (prev === -1) {
return index;
}
return Math.max(prev, index);
}
}, -1);
const target = Math.min(schema.items.length - 1, currentIndex - count + 2);
void moveConstituents({
itemID: schema.id,
data: {
items: [activeCst.id],
move_to: target
}
});
}
return (
<div className={cn('flex gap-0.5', className)}>
<MiniButton
title='Перейти к концептуальной схеме'
icon={<IconRSForm size='1rem' className='icon-primary' />}
onClick={navigateRSForm}
/>
<MiniButton
title='Создать конституенту'
icon={<IconNewItem size='1rem' className='icon-green' />}
onClick={createCst}
disabled={isProcessing}
/>
<MiniButton
title='Клонировать конституенту'
icon={<IconClone size='1rem' className='icon-green' />}
onClick={cloneCst}
disabled={!activeCst || isProcessing}
/>
<MiniButton
title='Удалить выделенную конституенту'
onClick={promptDeleteCst}
icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={!activeCst || isProcessing || activeCst?.is_inherited}
/>
<MiniButton
title='Переместить вверх'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={moveUp}
disabled={!activeCst || isProcessing || schema.items.length < 2 || hasSearch}
/>
<MiniButton
title='Переместить вниз'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={moveDown}
disabled={!activeCst || isProcessing || schema.items.length < 2 || hasSearch}
/>
</div>
);
}

View File

@ -1,45 +0,0 @@
import { useState } from 'react';
import { useRSFormSuspense } from '@/features/rsform/backend/use-rsform';
import { RSFormStats } from '@/features/rsform/components/rsform-stats';
import { ViewConstituents } from '@/features/rsform/components/view-constituents';
import { useFitHeight } from '@/stores/app-layout';
import { ToolbarConstituents } from './toolbar-constituents';
interface ViewSchemaProps {
schemaID: number;
}
export function ViewSchema({ schemaID }: ViewSchemaProps) {
const { schema } = useRSFormSuspense({ itemID: schemaID });
const [activeID, setActiveID] = useState<number | null>(null);
const activeCst = activeID ? schema.cstByID.get(activeID) ?? null : null;
const listHeight = useFitHeight('19rem', '10rem');
return (
<div className='grid h-full relative cc-fade-in' style={{ gridTemplateRows: '1fr auto' }}>
<ToolbarConstituents
className='absolute -top-7 left-1'
schema={schema}
activeCst={activeCst}
setActive={setActiveID}
resetActive={() => setActiveID(null)}
/>
<ViewConstituents
dense
noBorder
className='border-y rounded-none'
schema={schema}
activeCst={activeCst}
onActivate={cst => setActiveID(cst.id)}
maxListHeight={listHeight}
/>
<RSFormStats className='pr-4 py-2 ml-[-1rem]' stats={schema.stats} />
</div>
);
}

View File

@ -1,18 +1,18 @@
'use client';
import React from 'react';
import { toast } from 'react-toastify';
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help';
import { IconShowSidebar } from '@/features/library/components/icon-show-sidebar';
import { type OssNode } from '@/features/oss/models/oss-layout';
import { BadgeHelp } from '@/features/help/components';
import { MiniButton } from '@/components/control';
import {
IconConceptBlock,
IconDestroy,
IconEdit2,
IconExecute,
IconFitImage,
IconFixLayout,
IconNewItem,
IconReset,
IconSave,
@ -21,12 +21,14 @@ import {
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { isIOS, prepareTooltip } from '@/utils/utils';
import { prepareTooltip } from '@/utils/utils';
import { OperationType } from '../../../backend/types';
import { useExecuteOperation } from '../../../backend/use-execute-operation';
import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout';
import { NodeType } from '../../../models/oss';
import { LayoutManager } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context';
import { useOssFlow } from './oss-flow-context';
@ -37,10 +39,6 @@ interface ToolbarOssGraphProps extends Styling {
onCreateBlock: () => void;
onDelete: () => void;
onResetPositions: () => void;
isContextMenuOpen: boolean;
openContextMenu: (node: OssNode, clientX: number, clientY: number) => void;
hideContextMenu: () => void;
}
export function ToolbarOssGraph({
@ -48,16 +46,12 @@ export function ToolbarOssGraph({
onCreateBlock,
onDelete,
onResetPositions,
isContextMenuOpen,
openContextMenu,
hideContextMenu,
className,
...restProps
}: ToolbarOssGraphProps) {
const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit();
const isProcessing = useMutatingOss();
const { resetView, nodes } = useOssFlow();
const { resetView } = useOssFlow();
const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedBlock =
@ -65,31 +59,67 @@ export function ToolbarOssGraph({
const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout();
const { executeOperation } = useExecuteOperation();
const showOptions = useDialogsStore(state => state.showOssOptions);
const showSidePanel = usePreferencesStore(state => state.showOssSidePanel);
const toggleShowSidePanel = usePreferencesStore(state => state.toggleShowOssSidePanel);
const showEditOperation = useDialogsStore(state => state.showEditOperation);
const showEditBlock = useDialogsStore(state => state.showEditBlock);
const showOssOptions = useDialogsStore(state => state.showOssOptions);
const readyForSynthesis = (() => {
if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) {
return false;
}
if (selectedOperation.result) {
return false;
}
const argumentIDs = schema.graph.expandInputs([selectedOperation.id]);
if (!argumentIDs || argumentIDs.length < 1) {
return false;
}
const argumentOperations = argumentIDs.map(id => schema.operationByID.get(id)!);
if (argumentOperations.some(item => item.result === null)) {
return false;
}
return true;
})();
function handleFixLayout() {
// TODO: implement layout algorithm
toast.info('Еще не реализовано');
}
function handleShowOptions() {
showOptions();
showOssOptions();
}
function handleSavePositions() {
void updateLayout({ itemID: schema.id, data: getLayout() });
}
function handleEditItem(event: React.MouseEvent<HTMLButtonElement>) {
if (isContextMenuOpen) {
hideContextMenu();
function handleOperationExecute() {
if (!readyForSynthesis || !selectedOperation) {
return;
}
const nodeID = selectedOperation?.nodeID ?? selectedBlock?.nodeID;
if (!nodeID) {
return;
void executeOperation({
itemID: schema.id, //
data: { target: selectedOperation.id, layout: getLayout() }
});
}
const node = nodes.find(node => node.id === nodeID);
if (node) {
openContextMenu(node, event.clientX, event.clientY);
function handleEditItem() {
if (selectedOperation) {
showEditOperation({
manager: new LayoutManager(schema, getLayout()),
target: selectedOperation
});
} else if (selectedBlock) {
showEditBlock({
manager: new LayoutManager(schema, getLayout()),
target: selectedBlock
});
}
}
@ -115,9 +145,12 @@ export function ToolbarOssGraph({
onClick={resetView}
/>
<MiniButton
title='Панель содержания КС'
icon={<IconShowSidebar value={showSidePanel} isBottom={false} size='1.25rem' />}
onClick={toggleShowSidePanel}
title='Исправить позиции узлов'
icon={<IconFixLayout size='1.25rem' className='icon-primary' />}
onClick={handleFixLayout}
disabled={
selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION)
}
/>
<MiniButton
title='Настройки отображения'
@ -136,12 +169,11 @@ export function ToolbarOssGraph({
disabled={isProcessing}
/>
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', isIOS() ? '' : 'Правый клик')}
hideTitle={isContextMenuOpen}
aria-label='Редактировать выбранную'
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')}
aria-label='Новый блок'
icon={<IconConceptBlock size='1.25rem' className='icon-green' />}
onClick={onCreateBlock}
disabled={isProcessing}
/>
<MiniButton
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
@ -151,13 +183,18 @@ export function ToolbarOssGraph({
disabled={isProcessing}
/>
<MiniButton
titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')}
aria-label='Новый блок'
icon={<IconConceptBlock size='1.25rem' className='icon-green' />}
onClick={onCreateBlock}
disabled={isProcessing}
title='Активировать операцию'
icon={<IconExecute size='1.25rem' className='icon-green' />}
onClick={handleOperationExecute}
disabled={isProcessing || selectedItems.length !== 1 || !readyForSynthesis}
/>
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')}
aria-label='Редактировать выбранную'
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
/>
<MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
aria-label='Удалить выбранную'

View File

@ -3,7 +3,6 @@ import { type Node, useReactFlow } from 'reactflow';
import { type IOssLayout } from '../../../backend/types';
import { type IOperationSchema } from '../../../models/oss';
import { type Position2D } from '../../../models/oss-layout';
import { OPERATION_NODE_HEIGHT, OPERATION_NODE_WIDTH } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from './graph/block-node';
@ -15,24 +14,22 @@ export function useGetLayout() {
return function getLayout(): IOssLayout {
const nodes = getNodes();
const nodeById = new Map(nodes.map(node => [node.id, node]));
return [
...nodes
return {
operations: nodes
.filter(node => node.type !== 'block')
.map(node => ({
nodeID: node.id,
...computeAbsolutePosition(node, schema, nodeById),
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT
id: schema.itemByNodeID.get(node.id)!.id,
...computeAbsolutePosition(node, schema, nodeById)
})),
...nodes
blocks: nodes
.filter(node => node.type === 'block')
.map(node => ({
nodeID: node.id,
id: schema.itemByNodeID.get(node.id)!.id,
...computeAbsolutePosition(node, schema, nodeById),
width: node.width ?? BLOCK_NODE_MIN_WIDTH,
height: node.height ?? BLOCK_NODE_MIN_HEIGHT
}))
];
};
};
}

Some files were not shown because too many files have changed in this diff Show More