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['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible']) self.assertEqual(response.data['visible'], data['visible'])
self.assertEqual(response.data['read_only'], data['read_only']) self.assertEqual(response.data['read_only'], data['read_only'])
self.assertEqual(oss.layout().data['operations'], [])
self.assertEqual(oss.layout().data['blocks'], [])
self.logout() self.logout()
data = {'title': 'Title2'} data = {'title': 'Title2'}

View File

@ -41,7 +41,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
else: else:
serializer.save() serializer.save()
if serializer.data.get('item_type') == m.LibraryItemType.OPERATION_SCHEMA: 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: def perform_update(self, serializer) -> None:
instance = serializer.save() 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( data = JSONField(
verbose_name='Расположение', verbose_name='Расположение',
default=list default=dict
) )
class Meta: class Meta:

View File

@ -40,7 +40,7 @@ class OperationSchema:
def create(**kwargs) -> 'OperationSchema': def create(**kwargs) -> 'OperationSchema':
''' Create LibraryItem via OperationSchema. ''' ''' Create LibraryItem via OperationSchema. '''
model = LibraryItem.objects.create(item_type=LibraryItemType.OPERATION_SCHEMA, **kwargs) 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) return OperationSchema(model)
@staticmethod @staticmethod

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,11 +55,11 @@ class TestOssViewset(EndpointTester):
alias='3', alias='3',
operation_type=OperationType.SYNTHESIS operation_type=OperationType.SYNTHESIS
) )
self.layout_data = [ self.layout_data = {'operations': [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'id': self.operation1.pk, 'x': 0, 'y': 0},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'id': self.operation2.pk, 'x': 0, 'y': 0},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40} {'id': self.operation3.pk, 'x': 0, 'y': 0},
] ], 'blocks': []}
layout = self.owned.layout() layout = self.owned.layout()
layout.data = self.layout_data layout.data = self.layout_data
layout.save() layout.save()
@ -107,9 +107,10 @@ class TestOssViewset(EndpointTester):
self.assertEqual(arguments[1]['argument'], self.operation2.pk) self.assertEqual(arguments[1]['argument'], self.operation2.pk)
layout = response.data['layout'] layout = response.data['layout']
self.assertEqual(layout[0], self.layout_data[0]) self.assertEqual(layout['blocks'], [])
self.assertEqual(layout[1], self.layout_data[1]) self.assertEqual(layout['operations'][0], {'id': self.operation1.pk, 'x': 0, 'y': 0})
self.assertEqual(layout[2], self.layout_data[2]) 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.executeOK(item=self.unowned_id)
self.executeForbidden(item=self.private_id) self.executeForbidden(item=self.private_id)
@ -125,21 +126,23 @@ class TestOssViewset(EndpointTester):
self.populateData() self.populateData()
self.executeBadData(item=self.owned_id) self.executeBadData(item=self.owned_id)
data = {'data': []} data = {'operations': [], 'blocks': []}
self.executeOK(data=data) self.executeOK(data=data)
data = {'data': [ data = {
{'nodeID': 'o' + str(self.operation1.pk), 'x': 42.1, 'y': 1337, 'width': 150, 'height': 40}, 'operations': [
{'nodeID': 'o' + str(self.operation2.pk), 'x': 36.1, 'y': 1437, 'width': 150, 'height': 40}, {'id': self.operation1.pk, 'x': 42.1, 'y': 1337},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 36.1, 'y': 1435, 'width': 150, 'height': 40} {'id': self.operation2.pk, 'x': 36.1, 'y': 1437},
]} {'id': self.operation3.pk, 'x': 36.1, 'y': 1435}
], 'blocks': []
}
self.toggle_admin(True) self.toggle_admin(True)
self.executeOK(data=data, item=self.unowned_id) self.executeOK(data=data, item=self.unowned_id)
self.toggle_admin(False) self.toggle_admin(False)
self.executeOK(data=data, item=self.owned_id) self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db() 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.unowned_id)
self.executeForbidden(data=data, item=self.private_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. ''' ''' Endpoint: Update schema layout. '''
serializer = s.LayoutSerializer(data=request.data) serializer = s.LayoutSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 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) return Response(status=c.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -120,8 +120,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
children_operations: list[m.Operation] = serializer.validated_data['children_operations'] children_operations: list[m.Operation] = serializer.validated_data['children_operations']
with transaction.atomic(): with transaction.atomic():
new_block = oss.create_block(**serializer.validated_data['item_data']) new_block = oss.create_block(**serializer.validated_data['item_data'])
layout.append({ layout['blocks'].append({
'nodeID': 'b' + str(new_block.pk), 'id': new_block.pk,
'x': serializer.validated_data['position_x'], 'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'], 'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'], 'width': serializer.validated_data['width'],
@ -205,7 +205,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss = m.OperationSchema(self.get_object()) oss = m.OperationSchema(self.get_object())
block = cast(m.Block, serializer.validated_data['target']) block = cast(m.Block, serializer.validated_data['target'])
layout = serializer.validated_data['layout'] 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(): with transaction.atomic():
oss.delete_block(block) oss.delete_block(block)
oss.update_layout(layout) oss.update_layout(layout)
@ -274,12 +274,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
layout = serializer.validated_data['layout'] layout = serializer.validated_data['layout']
with transaction.atomic(): with transaction.atomic():
new_operation = oss.create_operation(**serializer.validated_data['item_data']) new_operation = oss.create_operation(**serializer.validated_data['item_data'])
layout.append({ layout['operations'].append({
'nodeID': 'o' + str(new_operation.pk), 'id': new_operation.pk,
'x': serializer.validated_data['position_x'], 'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'], 'y': serializer.validated_data['position_y']
'width': serializer.validated_data['width'],
'height': serializer.validated_data['height']
}) })
oss.update_layout(layout) oss.update_layout(layout)
@ -344,9 +342,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
operation.title = serializer.validated_data['item_data']['title'] operation.title = serializer.validated_data['item_data']['title']
if 'description' in serializer.validated_data['item_data']: if 'description' in serializer.validated_data['item_data']:
operation.description = serializer.validated_data['item_data']['description'] operation.description = serializer.validated_data['item_data']['description']
if 'parent' in serializer.validated_data['item_data']: operation.save(update_fields=['alias', 'title', 'description'])
operation.parent = serializer.validated_data['item_data']['parent']
operation.save(update_fields=['alias', 'title', 'description', 'parent'])
if operation.result is not None: if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result) 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']) operation = cast(m.Operation, serializer.validated_data['target'])
old_schema = operation.result old_schema = operation.result
layout = serializer.validated_data['layout'] 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(): with transaction.atomic():
oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents']) oss.delete_operation(operation.pk, serializer.validated_data['keep_constituents'])
oss.update_layout(layout) oss.update_layout(layout)

View File

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

View File

@ -29,18 +29,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <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> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,7 @@ export function Button({
className={cn( className={cn(
'inline-flex gap-2 items-center justify-center', 'inline-flex gap-2 items-center justify-center',
'font-medium select-none disabled:cursor-auto disabled:opacity-75', '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', dense ? 'px-1' : 'px-3 py-1',
loading ? 'cursor-progress' : 'cursor-pointer', loading ? 'cursor-progress' : 'cursor-pointer',
noOutline ? 'outline-hidden focus-visible:bg-selected' : 'focus-outline', 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 { globalIDs } from '@/utils/constants';
import { type Button } from '../props'; import { type Button } from '../props';
import { cn } from '../utils';
interface MiniButtonProps extends Button { interface MiniButtonProps extends Button {
/** Button type. */ /** Button type. */
@ -36,12 +37,11 @@ export function MiniButton({
<button <button
type={type} type={type}
tabIndex={tabIndex ?? -1} tabIndex={tabIndex ?? -1}
className={cn( className={clsx(
'rounded-lg', 'rounded-lg',
'text-muted-foreground cc-animate-color', 'cc-controls cc-animate-background',
'cursor-pointer disabled:cursor-auto disabled:opacity-75', 'cursor-pointer disabled:cursor-auto',
(!tabIndex || tabIndex === -1) && 'outline-hidden', noHover ? 'outline-hidden' : 'cc-hover',
!noHover && 'cc-hover-pulse',
!noPadding && 'px-1 py-1', !noPadding && 'px-1 py-1',
className className
)} )}

View File

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

View File

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

View File

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

View File

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

View File

@ -36,9 +36,9 @@ export function DropdownButton({
className={cn( className={cn(
'px-3 py-1 inline-flex items-center gap-2', 'px-3 py-1 inline-flex items-center gap-2',
'text-left text-sm text-ellipsis whitespace-nowrap', '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', '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 className
)} )}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined} 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 { LuFilterX as IconFilterReset } from 'react-icons/lu';
export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi'; export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi';
export { LuTriangleAlert as IconAlert } from 'react-icons/lu'; 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 ======= // ===== UI elements =======
export { BiX as IconClose } from 'react-icons/bi'; 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 { LuView as IconDBStructure } from 'react-icons/lu';
export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu'; export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu';
export { LuImage as IconImage } 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 { GoVersions as IconVersions } from 'react-icons/go';
export { TbColumnsOff as IconListOff } from 'react-icons/tb';
export { LuAtSign as IconTerm } from 'react-icons/lu'; export { LuAtSign as IconTerm } from 'react-icons/lu';
export { LuSubscript as IconAlias } from 'react-icons/lu'; export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb'; 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 { BiFont as IconTextOff } from 'react-icons/bi';
export { TbCircleLetterM as IconTypeGraph } from 'react-icons/tb'; export { TbCircleLetterM as IconTypeGraph } from 'react-icons/tb';
export { RiTreeLine as IconTree } from 'react-icons/ri'; export { RiTreeLine as IconTree } from 'react-icons/ri';
export { LuKeyboard as IconKeyboard } from 'react-icons/lu'; export { FaRegKeyboard as IconControls } from 'react-icons/fa6';
export { LuKeyboardOff as IconKeyboardOff } from 'react-icons/lu';
export { RiLockLine as IconImmutable } from 'react-icons/ri'; export { RiLockLine as IconImmutable } from 'react-icons/ri';
export { RiLockUnlockLine as IconMutable } from 'react-icons/ri'; export { RiLockUnlockLine as IconMutable } from 'react-icons/ri';
export { RiOpenSourceLine as IconPublic } 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'; export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI ======= // ======== Graph UI =======
export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi'; export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi'; export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu'; export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';

View File

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

View File

@ -115,7 +115,7 @@ export function ComboMulti<Option>({
<IconRemove <IconRemove
tabIndex={-1} tabIndex={-1}
size='1rem' 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} onClick={handleClear}
/> />
) : null} ) : null}

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ export function ModalBackdrop({ onHide }: ModalBackdropProps) {
return ( return (
<> <>
<div className='z-bottom fixed inset-0 backdrop-blur-[3px] opacity-50' /> <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'; 'use client';
import { type HelpTopic } from '@/features/help'; 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 { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -89,7 +89,7 @@ export function ModalForm({
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={handleCancel} /> <ModalBackdrop onHide={handleCancel} />
<form <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' role='dialog'
onSubmit={handleSubmit} onSubmit={handleSubmit}
aria-labelledby='modal-title' aria-labelledby='modal-title'

View File

@ -6,7 +6,7 @@ export function ModalLoader() {
return ( return (
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop /> <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} /> <Loader circular scale={6} />
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import clsx from 'clsx'; 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 { useEscapeKey } from '@/hooks/use-escape-key';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -39,7 +39,7 @@ export function ModalView({
return ( return (
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={hideDialog} /> <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?.() ? ( {helpTopic && !hideHelpWhen?.() ? (
<BadgeHelp <BadgeHelp
topic={helpTopic} topic={helpTopic}

View File

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

View File

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

View File

@ -57,7 +57,7 @@ export function ValueIcon({
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
aria-label={title} 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> <span id={id}>{value}</span>
</div> </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 ( return (
<div tabIndex={-1} id={`help-${topic}`} className={cn(padding, className)} style={style}> <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 <Tooltip
clickable clickable
anchorSelect={`#help-${topic}`} anchorSelect={`#help-${topic}`}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ export function labelHelpTopic(topic: HelpTopic): string {
case HelpTopic.INTERFACE: return '🌀 Интерфейс'; case HelpTopic.INTERFACE: return '🌀 Интерфейс';
case HelpTopic.UI_LIBRARY: return 'Библиотека'; case HelpTopic.UI_LIBRARY: return 'Библиотека';
case HelpTopic.UI_RS_MENU: 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_LIST: return 'Список конституент';
case HelpTopic.UI_RS_EDITOR: return 'Редактор конституенты'; case HelpTopic.UI_RS_EDITOR: return 'Редактор конституенты';
case HelpTopic.UI_GRAPH_TERM: return 'Граф термов'; case HelpTopic.UI_GRAPH_TERM: return 'Граф термов';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { HelpTopic } from '@/features/help'; 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 { useRoleStore, UserRole } from '@/features/users';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';

View File

@ -2,7 +2,7 @@
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { HelpTopic } from '@/features/help'; 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 { type IRSForm } from '@/features/rsform';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
@ -10,46 +10,29 @@ import { MiniButton } from '@/components/control';
import { IconDestroy, IconSave, IconShare } from '@/components/icons'; import { IconDestroy, IconSave, IconShare } from '@/components/icons';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences';
import { tooltipText } from '@/utils/labels'; import { tooltipText } from '@/utils/labels';
import { prepareTooltip, sharePage } from '@/utils/utils'; import { prepareTooltip, sharePage } from '@/utils/utils';
import { AccessPolicy, type ILibraryItem, LibraryItemType } from '../backend/types'; import { AccessPolicy, type ILibraryItem, LibraryItemType } from '../backend/types';
import { useMutatingLibrary } from '../backend/use-mutating-library'; import { useMutatingLibrary } from '../backend/use-mutating-library';
import { IconShowSidebar } from './icon-show-sidebar';
import { MiniSelectorOSS } from './mini-selector-oss'; import { MiniSelectorOSS } from './mini-selector-oss';
interface ToolbarItemCardProps { interface ToolbarItemCardProps {
className?: string; className?: string;
isNarrow: boolean;
onSubmit: () => void; onSubmit: () => void;
isMutable: boolean; isMutable: boolean;
schema: ILibraryItem; schema: ILibraryItem;
deleteSchema: () => void; deleteSchema: () => void;
} }
export function ToolbarItemCard({ export function ToolbarItemCard({ className, schema, onSubmit, isMutable, deleteSchema }: ToolbarItemCardProps) {
className,
isNarrow,
schema,
onSubmit,
isMutable,
deleteSchema
}: ToolbarItemCardProps) {
const role = useRoleStore(state => state.role); const role = useRoleStore(state => state.role);
const router = useConceptNavigation(); const router = useConceptNavigation();
const { isModified } = useModificationStore(); const { isModified } = useModificationStore();
const isProcessing = useMutatingLibrary(); const isProcessing = useMutatingLibrary();
const canSave = isModified && !isProcessing; 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 = (() => { const ossSelector = (() => {
if (schema.item_type !== LibraryItemType.RSFORM) { if (schema.item_type !== LibraryItemType.RSFORM) {
return null; return null;
@ -93,15 +76,6 @@ export function ToolbarItemCard({
disabled={!isMutable || isProcessing || role < UserRole.OWNER} disabled={!isMutable || isProcessing || role < UserRole.OWNER}
/> />
) : null} ) : 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} /> <BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} />
</div> </div>
); );

View File

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

View File

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

View File

@ -58,7 +58,7 @@ export function LibraryPage() {
<MiniButton <MiniButton
title='Выгрузить в формате CSV' title='Выгрузить в формате CSV'
className='absolute z-tooltip -top-8 right-6 hidden sm:block' 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} onClick={handleDownloadCSV}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@ -90,7 +90,7 @@ export class OssLoader {
this.graph.topologicalOrder().forEach(operationID => { this.graph.topologicalOrder().forEach(operationID => {
const operation = this.operationByID.get(operationID)!; const operation = this.operationByID.get(operationID)!;
const schema = this.items.find(item => item.id === operation.result); 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.x = position?.x ?? 0;
operation.y = position?.y ?? 0; operation.y = position?.y ?? 0;
operation.is_consolidation = this.inferConsolidation(operationID); operation.is_consolidation = this.inferConsolidation(operationID);
@ -104,7 +104,7 @@ export class OssLoader {
private inferBlockAttributes() { private inferBlockAttributes() {
this.oss.blocks.forEach(block => { 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.x = geometry?.x ?? 0;
block.y = geometry?.y ?? 0; block.y = geometry?.y ?? 0;
block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH; 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. */ /** Represents {@link IConstituenta} reference. */
export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>; export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>;
/** Represents {@link IOperationSchema} node position. */ /** Represents {@link IOperation} position. */
export type INodePosition = z.infer<typeof schemaNodePosition>; export type IOperationPosition = z.infer<typeof schemaOperationPosition>;
/** Represents {@link IBlock} position. */
export type IBlockPosition = z.infer<typeof schemaBlockPosition>;
// ====== Schemas ====== // ====== Schemas ======
export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]); export const schemaOperationType = z.enum(Object.values(OperationType) as [OperationType, ...OperationType[]]);
@ -105,15 +108,24 @@ export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({
substitution_term: z.string() substitution_term: z.string()
}); });
export const schemaNodePosition = z.strictObject({ export const schemaOperationPosition = z.strictObject({
nodeID: z.string(), id: z.number(),
x: z.number(),
y: z.number()
});
export const schemaBlockPosition = z.strictObject({
id: z.number(),
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
width: z.number(), width: z.number(),
height: 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({ export const schemaOperationSchema = schemaLibraryItem.extend({
editors: z.number().array(), editors: z.number().array(),
@ -176,8 +188,6 @@ export const schemaCreateOperation = z.strictObject({
}), }),
position_x: z.number(), position_x: z.number(),
position_y: z.number(), position_y: z.number(),
width: z.number(),
height: z.number(),
arguments: z.array(z.number()), arguments: z.array(z.number()),
create_schema: z.boolean() create_schema: z.boolean()
}); });

View File

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

View File

@ -82,18 +82,21 @@ export function PickMultiOperation({ rows, items, value, onChange, className, ..
<div className='flex w-fit'> <div className='flex w-fit'>
<MiniButton <MiniButton
title='Удалить' title='Удалить'
noHover
className='px-0' className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />} icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(props.row.original.id)} onClick={() => handleDelete(props.row.original.id)}
/> />
<MiniButton <MiniButton
title='Переместить выше' title='Переместить выше'
noHover
className='px-0' className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />} icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(props.row.original.id)} onClick={() => handleMoveUp(props.row.original.id)}
/> />
<MiniButton <MiniButton
title='Переместить ниже' title='Переместить ниже'
noHover
className='px-0' className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />} icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(props.row.original.id)} 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 { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-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 { MiniButton } from '@/components/control';
import { IconReset } from '@/components/icons'; import { IconReset } from '@/components/icons';
@ -61,6 +61,7 @@ export function DlgChangeInputSchema() {
<Label text='Загружаемая концептуальная схема' /> <Label text='Загружаемая концептуальная схема' />
<MiniButton <MiniButton
title='Сбросить выбор схемы' title='Сбросить выбор схемы'
noHover
noPadding noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setValue('input', null)} onClick={() => setValue('input', null)}

View File

@ -84,9 +84,14 @@ export function DlgCreateBlock() {
className='w-160 px-6 h-110' className='w-160 px-6 h-110'
helpTopic={HelpTopic.CC_OSS} helpTopic={HelpTopic.CC_OSS}
> >
<Tabs className='grid' selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}> <Tabs
<TabList className='z-pop mx-auto -mb-5 flex border divide-x rounded-none'> selectedTabClassName='cc-selected'
<TabLabel title='Основные атрибуты блока' label='Паспорт' /> 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 <TabLabel
title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`} title={`Выбор вложенных узлов: [${children_operations.length + children_blocks.length}]`}
label={`Содержимое${children_operations.length + children_blocks.length > 0 ? '*' : ''}`} 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 { type ICreateOperationDTO, OperationType, schemaCreateOperation } from '../../backend/types';
import { useCreateOperation } from '../../backend/use-create-operation'; import { useCreateOperation } from '../../backend/use-create-operation';
import { describeOperationType, labelOperationType } from '../../labels'; 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 { TabInputOperation } from './tab-input-operation';
import { TabSynthesisOperation } from './tab-synthesis-operation'; import { TabSynthesisOperation } from './tab-synthesis-operation';
@ -54,8 +54,6 @@ export function DlgCreateOperation() {
position_x: defaultX, position_x: defaultX,
position_y: defaultY, position_y: defaultY,
arguments: initialInputs, arguments: initialInputs,
width: OPERATION_NODE_WIDTH,
height: OPERATION_NODE_HEIGHT,
create_schema: false, create_schema: false,
layout: manager.layout layout: manager.layout
}, },
@ -100,11 +98,12 @@ export function DlgCreateOperation() {
helpTopic={HelpTopic.CC_OSS} helpTopic={HelpTopic.CC_OSS}
> >
<Tabs <Tabs
selectedTabClassName='cc-selected'
className='grid' className='grid'
selectedIndex={activeTab} selectedIndex={activeTab}
onSelect={(index, last) => handleSelectTab(index as TabID, last as TabID)} 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 <TabLabel
title={describeOperationType(OperationType.INPUT)} title={describeOperationType(OperationType.INPUT)}
label={labelOperationType(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 { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-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 { MiniButton } from '@/components/control';
import { IconReset } from '@/components/icons'; import { IconReset } from '@/components/icons';
@ -97,6 +97,7 @@ export function TabInputOperation() {
<Label text='Загружаемая концептуальная схема' /> <Label text='Загружаемая концептуальная схема' />
<MiniButton <MiniButton
title='Сбросить выбор схемы' title='Сбросить выбор схемы'
noHover
noPadding noPadding
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setValue('item_data.result', null)} onClick={() => setValue('item_data.result', null)}

View File

@ -43,7 +43,7 @@ export function DlgEditBlock() {
function onSubmit(data: IUpdateBlockDTO) { function onSubmit(data: IUpdateBlockDTO) {
if (data.item_data.parent !== target.parent) { 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; data.layout = manager.layout;
} }
return updateBlock({ itemID: manager.oss.id, data }); return updateBlock({ itemID: manager.oss.id, data });

View File

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

View File

@ -3,7 +3,7 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { useRSForms } from '@/features/rsform/backend/use-rsforms'; 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 { TextArea } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';

View File

@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { type ILibraryItem } from '@/features/library'; import { type ILibraryItem } from '@/features/library';
import { useLibrary } from '@/features/library/backend/use-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 { 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 { MiniButton } from '@/components/control';
import { Loader } from '@/components/loader'; 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 IOperationSchema } from './oss';
import { type Position2D, type Rectangle2D } from './oss-layout'; 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 export const GRID_SIZE = 10; // pixels - size of OSS grid
const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes const MIN_DISTANCE = 2 * GRID_SIZE; // pixels - minimum distance between nodes
export const OPERATION_NODE_WIDTH = 150; const OPERATION_NODE_WIDTH = 150;
export const OPERATION_NODE_HEIGHT = 40; const OPERATION_NODE_HEIGHT = 40;
/** Layout manipulations for {@link IOperationSchema}. */ /** Layout manipulations for {@link IOperationSchema}. */
export class LayoutManager { export class LayoutManager {
@ -24,40 +30,52 @@ export class LayoutManager {
} }
/** Calculate insert position for a new {@link IOperation} */ /** Calculate insert position for a new {@link IOperation} */
newOperationPosition(data: ICreateOperationDTO): Rectangle2D { newOperationPosition(data: ICreateOperationDTO): Position2D {
const result = { x: data.position_x, y: data.position_y, width: data.width, height: data.height }; let result = { x: data.position_x, y: data.position_y };
const parentNode = this.layout.find(pos => pos.nodeID === `b${data.item_data.parent}`) ?? null; const operations = this.layout.operations;
const operations = this.layout.filter(pos => pos.nodeID.startsWith('o')); const parentNode = this.layout.blocks.find(pos => pos.id === data.item_data.parent);
if (operations.length === 0) {
return result;
}
if (data.arguments.length !== 0) { if (data.arguments.length !== 0) {
const pos = calculatePositionFromArgs( result = calculatePositionFromArgs(data.arguments, operations);
operations.filter(node => data.arguments.includes(Number(node.nodeID.slice(1))))
);
result.x = pos.x;
result.y = pos.y;
} else if (parentNode) { } else if (parentNode) {
result.x = parentNode.x + MIN_DISTANCE; result.x = parentNode.x + MIN_DISTANCE;
result.y = parentNode.y + MIN_DISTANCE; result.y = parentNode.y + MIN_DISTANCE;
} else { } else {
const pos = this.calculatePositionForFreeOperation(result); result = this.calculatePositionForFreeOperation(result);
result.x = pos.x;
result.y = pos.y;
} }
preventOverlap(result, operations); result = preventOverlap(
this.extendParentBounds(parentNode, result); { ...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} */ /** Calculate insert position for a new {@link IBlock} */
newBlockPosition(data: ICreateBlockDTO): Rectangle2D { newBlockPosition(data: ICreateBlockDTO): Rectangle2D {
const block_nodes = data.children_blocks 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); .filter(node => !!node);
const operation_nodes = data.children_operations 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); .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 }; 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 (block_nodes.length === 0 && operation_nodes.length === 0) {
if (parentNode) { if (parentNode) {
const siblings = this.oss.blocks const siblings = this.oss.blocks.filter(block => block.parent === parentNode.id).map(block => block.id);
.filter(block => block.parent === data.item_data.parent)
.map(block => block.nodeID);
if (siblings.length > 0) { if (siblings.length > 0) {
preventOverlap( result = preventOverlap(
result, result,
this.layout.filter(node => siblings.includes(node.nodeID)) this.layout.blocks.filter(block => siblings.includes(block.id))
); );
} }
} else { } 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) { if (rootBlocks.length > 0) {
preventOverlap( result = preventOverlap(
result, 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; return result;
} }
/** Update layout when parent changes */ /** Update layout when parent changes */
onChangeParent(targetID: string, newParent: string | null) { onOperationChangeParent(targetID: number, newParent: number | null) {
const targetNode = this.layout.find(pos => pos.nodeID === targetID); console.error('not implemented', targetID, newParent);
if (!targetNode) {
return;
}
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) { /** Update layout when parent changes */
if (!parent) { onBlockChangeParent(targetID: number, newParent: number | null) {
return; console.error('not implemented', targetID, newParent);
}
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
} }
private calculatePositionForFreeOperation(initial: Position2D): Position2D { private calculatePositionForFreeOperation(initial: Position2D): Position2D {
if (this.oss.operations.length === 0) { const operations = this.layout.operations;
if (operations.length === 0) {
return initial; return initial;
} }
const freeInputs = this.oss.operations const freeInputs = this.oss.operations
.filter(operation => operation.arguments.length === 0 && operation.parent === null) .filter(operation => operation.arguments.length === 0 && operation.parent === null)
.map(operation => operation.nodeID); .map(operation => operation.id);
let inputsPositions = this.layout.filter(pos => freeInputs.includes(pos.nodeID)); let inputsPositions = operations.filter(pos => freeInputs.includes(pos.id));
if (inputsPositions.length === 0) { 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 maxX = Math.max(...inputsPositions.map(node => node.x));
const minY = Math.min(...inputsPositions.map(node => node.y)); const minY = Math.min(...inputsPositions.map(node => node.y));
@ -162,8 +163,8 @@ export class LayoutManager {
} }
private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D { private calculatePositionForFreeBlock(initial: Rectangle2D): Rectangle2D {
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);
const blocksPositions = this.layout.filter(pos => rootBlocks.includes(pos.nodeID)); const blocksPositions = this.layout.blocks.filter(pos => rootBlocks.includes(pos.id));
if (blocksPositions.length === 0) { if (blocksPositions.length === 0) {
return initial; return initial;
} }
@ -171,23 +172,6 @@ export class LayoutManager {
const minY = Math.min(...blocksPositions.map(node => node.y)); const minY = Math.min(...blocksPositions.map(node => node.y));
return { ...initial, x: maxX + MIN_DISTANCE, y: minY }; 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 ======= // ======= 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; let hasOverlap: boolean;
do { do {
hasOverlap = false; hasOverlap = false;
for (const fixed of fixedRectangles) { for (const fixed of fixedRectangles) {
if (rectanglesOverlap(target, fixed)) { if (rectanglesOverlap(target, fixed)) {
hasOverlap = true; hasOverlap = true;
target.x += MIN_DISTANCE; const overlap = getOverlapAmount(target, fixed);
target.y += MIN_DISTANCE; if (overlap.x >= overlap.y) {
target.x += overlap.x + MIN_DISTANCE;
} else {
target.y += overlap.y + MIN_DISTANCE;
}
break; break;
} }
} }
} while (hasOverlap); } while (hasOverlap);
return target;
} }
function calculatePositionFromArgs(args: INodePosition[]): Position2D { function calculatePositionFromArgs(args: number[], operations: IOperationPosition[]): Position2D {
const maxY = Math.max(...args.map(node => node.y)); const argNodes = operations.filter(pos => args.includes(pos.id));
const minX = Math.min(...args.map(node => node.x)); const maxY = Math.max(...argNodes.map(node => node.y));
const maxX = Math.max(...args.map(node => node.x)); const minX = Math.min(...argNodes.map(node => node.x));
const maxX = Math.max(...argNodes.map(node => node.x));
return { return {
x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE, x: Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE,
y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE y: maxY + 2 * OPERATION_NODE_HEIGHT + MIN_DISTANCE
@ -227,23 +224,42 @@ function calculatePositionFromArgs(args: INodePosition[]): Position2D {
function calculatePositionFromChildren( function calculatePositionFromChildren(
initial: Rectangle2D, initial: Rectangle2D,
operations: INodePosition[], operations: IOperationPosition[],
blocks: INodePosition[] blocks: IBlockPosition[]
): Rectangle2D { ): Rectangle2D {
const allNodes = [...blocks, ...operations]; let left = undefined;
if (allNodes.length === 0) { let top = undefined;
return initial; 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; for (const operation of operations) {
const top = Math.min(...allNodes.map(n => n.y)) - MIN_DISTANCE; left = left === undefined ? operation.x - MIN_DISTANCE : Math.min(left, operation.x - MIN_DISTANCE);
const right = Math.max(...allNodes.map(n => n.x + n.width)) + MIN_DISTANCE; top = top === undefined ? operation.y - MIN_DISTANCE : Math.min(top, operation.y - MIN_DISTANCE);
const bottom = Math.max(...allNodes.map(n => n.y + n.height)) + 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 { return {
x: left, x: left ?? initial.x,
y: top, y: top ?? initial.y,
width: right - left, width: right !== undefined && left !== undefined ? right - left : initial.width,
height: bottom - top height: bottom !== undefined && top !== undefined ? bottom - top : initial.height
}; };
} }

View File

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

View File

@ -7,7 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { type IUpdateLibraryItemDTO, LibraryItemType, schemaUpdateLibraryItem } from '@/features/library'; import { type IUpdateLibraryItemDTO, LibraryItemType, schemaUpdateLibraryItem } from '@/features/library';
import { useUpdateItem } from '@/features/library/backend/use-update-item'; 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 { SubmitButton } from '@/components/control';
import { IconSave } from '@/components/icons'; import { IconSave } from '@/components/icons';

View File

@ -9,7 +9,7 @@ import {
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { ValueStats } from '@/components/view'; import { ValueStats } from '@/components/view';
import { type IOperationSchemaStats } from '../models/oss'; import { type IOperationSchemaStats } from '../../../models/oss';
interface OssStatsProps { interface OssStatsProps {
className?: string; className?: string;
@ -18,43 +18,48 @@ interface OssStatsProps {
export function OssStats({ className, stats }: OssStatsProps) { export function OssStats({ className, stats }: OssStatsProps) {
return ( 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 '> <div id='count_operations' className='w-fit flex gap-3 hover:cursor-default '>
<span>Всего</span> <span>Всего</span>
<span>{stats.count_all}</span> <span>{stats.count_all}</span>
</div> </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 <ValueStats
id='count_inputs' id='count_inputs'
title='Загрузка' title='Загрузка'
icon={<IconDownload size='1.25rem' />} icon={<IconDownload size='1.25rem' className='text-primary' />}
value={stats.count_inputs} value={stats.count_inputs}
/> />
<ValueStats <ValueStats
id='count_synthesis' id='count_synthesis'
title='Синтез' title='Синтез'
icon={<IconSynthesis size='1.25rem' />} icon={<IconSynthesis size='1.25rem' className='text-primary' />}
value={stats.count_synthesis} value={stats.count_synthesis}
/> />
<ValueStats <ValueStats
id='count_schemas' id='count_schemas'
title='Прикрепленные схемы' title='Прикрепленные схемы'
icon={<IconRSForm size='1.25rem' />} icon={<IconRSForm size='1.25rem' className='text-primary' />}
value={stats.count_schemas} value={stats.count_schemas}
/> />
<ValueStats <ValueStats
id='count_owned' id='count_owned'
title='Собственные' title='Собственные'
icon={<IconRSFormOwned size='1.25rem' />} icon={<IconRSFormOwned size='1.25rem' className='text-primary' />}
value={stats.count_owned} value={stats.count_owned}
/> />
<ValueStats <ValueStats
id='count_imported' id='count_imported'
title='Внешние' title='Внешние'
icon={<IconRSFormImported size='1.25rem' />} icon={<IconRSFormImported size='1.25rem' className='text-primary' />}
value={stats.count_schemas - stats.count_owned} 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 setHoverOperation = useOperationTooltipStore(state => state.setHoverItem);
const { addSelectedNodes } = useStoreApi().getState(); 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]); addSelectedNodes([node.id]);
setMenuProps({ setMenuProps({
item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null, item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null,
cursorX: clientX, cursorX: event.clientX,
cursorY: clientY cursorY: event.clientY
}); });
setIsOpen(true); setIsOpen(true);
setHoverOperation(null); setHoverOperation(null);
} }
@ -37,7 +42,7 @@ export function useContextMenu() {
return { return {
isOpen, isOpen,
menuProps, menuProps,
openContextMenu, handleContextMenu,
hideContextMenu hideContextMenu
}; };
} }

View File

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

View File

@ -6,7 +6,6 @@ import clsx from 'clsx';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow'; import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
import { useMainHeight } from '@/stores/app-layout'; import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels'; 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 { OssNodeTypes } from './graph/oss-node-types';
import { CoordinateDisplay } from './coordinate-display'; import { CoordinateDisplay } from './coordinate-display';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
import { SidePanel } from './side-panel';
import { ToolbarOssGraph } from './toolbar-oss-graph'; import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useDragging } from './use-dragging'; import { useDragging } from './use-dragging';
import { useGetLayout } from './use-get-layout'; import { useGetLayout } from './use-get-layout';
@ -54,7 +52,6 @@ export function OssFlow() {
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const showPanel = usePreferencesStore(state => state.showOssSidePanel);
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
@ -67,7 +64,7 @@ export function OssFlow() {
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation); const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
const showEditBlock = useDialogsStore(state => state.showEditBlock); 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 }); const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu });
function handleSavePositions() { function handleSavePositions() {
@ -138,8 +135,8 @@ export function OssFlow() {
}); });
} }
} else { } else {
if (node.data.operation) { if (node.data.operation?.result) {
navigateOperationSchema(node.data.operation.id); navigateOperationSchema(Number(node.id));
} }
} }
} }
@ -186,29 +183,19 @@ export function OssFlow() {
setMouseCoords(targetPosition); setMouseCoords(targetPosition);
} }
function handleNodeContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
openContextMenu(node, event.clientX, event.clientY);
}
return ( return (
<div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}> <div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}>
{showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null} {showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null}
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<ToolbarOssGraph <ToolbarOssGraph
className='absolute z-pop top-8 right-1/2 translate-x-1/2' className='absolute z-pop top-8 right-1/2 translate-x-1/2'
onCreateOperation={handleCreateOperation} onCreateOperation={handleCreateOperation}
onCreateBlock={handleCreateBlock} onCreateBlock={handleCreateBlock}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onResetPositions={resetGraph} onResetPositions={resetGraph}
openContextMenu={openContextMenu}
isContextMenuOpen={isContextMenuOpen}
hideContextMenu={hideContextMenu}
/> />
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<DiagramFlow <DiagramFlow
{...flowOptions} {...flowOptions}
className={clsx(!containMovement && 'cursor-relocate')} className={clsx(!containMovement && 'cursor-relocate')}
@ -222,22 +209,12 @@ export function OssFlow() {
showGrid={showGrid} showGrid={showGrid}
onClick={hideContextMenu} onClick={hideContextMenu}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleNodeContextMenu} onNodeContextMenu={handleContextMenu}
onContextMenu={hideContextMenu} onContextMenu={hideContextMenu}
onNodeDragStart={handleDragStart} onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag} onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop} 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> </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'; 'use client';
import React from 'react'; import { toast } from 'react-toastify';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help'; import { BadgeHelp } from '@/features/help/components';
import { IconShowSidebar } from '@/features/library/components/icon-show-sidebar';
import { type OssNode } from '@/features/oss/models/oss-layout';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { import {
IconConceptBlock, IconConceptBlock,
IconDestroy, IconDestroy,
IconEdit2, IconEdit2,
IconExecute,
IconFitImage, IconFitImage,
IconFixLayout,
IconNewItem, IconNewItem,
IconReset, IconReset,
IconSave, IconSave,
@ -21,12 +21,14 @@ import {
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences'; import { prepareTooltip } from '@/utils/utils';
import { isIOS, prepareTooltip } from '@/utils/utils';
import { OperationType } from '../../../backend/types';
import { useExecuteOperation } from '../../../backend/use-execute-operation';
import { useMutatingOss } from '../../../backend/use-mutating-oss'; import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout'; import { useUpdateLayout } from '../../../backend/use-update-layout';
import { NodeType } from '../../../models/oss'; import { NodeType } from '../../../models/oss';
import { LayoutManager } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context'; import { useOssEdit } from '../oss-edit-context';
import { useOssFlow } from './oss-flow-context'; import { useOssFlow } from './oss-flow-context';
@ -37,10 +39,6 @@ interface ToolbarOssGraphProps extends Styling {
onCreateBlock: () => void; onCreateBlock: () => void;
onDelete: () => void; onDelete: () => void;
onResetPositions: () => void; onResetPositions: () => void;
isContextMenuOpen: boolean;
openContextMenu: (node: OssNode, clientX: number, clientY: number) => void;
hideContextMenu: () => void;
} }
export function ToolbarOssGraph({ export function ToolbarOssGraph({
@ -48,16 +46,12 @@ export function ToolbarOssGraph({
onCreateBlock, onCreateBlock,
onDelete, onDelete,
onResetPositions, onResetPositions,
isContextMenuOpen,
openContextMenu,
hideContextMenu,
className, className,
...restProps ...restProps
}: ToolbarOssGraphProps) { }: ToolbarOssGraphProps) {
const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit(); const { schema, selectedItems, isMutable, canDeleteOperation: canDelete } = useOssEdit();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { resetView, nodes } = useOssFlow(); const { resetView } = useOssFlow();
const selectedOperation = const selectedOperation =
selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null; selectedItems.length === 1 && selectedItems[0].nodeType === NodeType.OPERATION ? selectedItems[0] : null;
const selectedBlock = const selectedBlock =
@ -65,31 +59,67 @@ export function ToolbarOssGraph({
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
const { executeOperation } = useExecuteOperation();
const showOptions = useDialogsStore(state => state.showOssOptions); const showEditOperation = useDialogsStore(state => state.showEditOperation);
const showSidePanel = usePreferencesStore(state => state.showOssSidePanel); const showEditBlock = useDialogsStore(state => state.showEditBlock);
const toggleShowSidePanel = usePreferencesStore(state => state.toggleShowOssSidePanel); 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() { function handleShowOptions() {
showOptions(); showOssOptions();
} }
function handleSavePositions() { function handleSavePositions() {
void updateLayout({ itemID: schema.id, data: getLayout() }); void updateLayout({ itemID: schema.id, data: getLayout() });
} }
function handleEditItem(event: React.MouseEvent<HTMLButtonElement>) { function handleOperationExecute() {
if (isContextMenuOpen) { if (!readyForSynthesis || !selectedOperation) {
hideContextMenu();
return; return;
} }
const nodeID = selectedOperation?.nodeID ?? selectedBlock?.nodeID; void executeOperation({
if (!nodeID) { itemID: schema.id, //
return; 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} onClick={resetView}
/> />
<MiniButton <MiniButton
title='Панель содержания КС' title='Исправить позиции узлов'
icon={<IconShowSidebar value={showSidePanel} isBottom={false} size='1.25rem' />} icon={<IconFixLayout size='1.25rem' className='icon-primary' />}
onClick={toggleShowSidePanel} onClick={handleFixLayout}
disabled={
selectedItems.length > 1 || (selectedItems.length > 0 && selectedItems[0].nodeType === NodeType.OPERATION)
}
/> />
<MiniButton <MiniButton
title='Настройки отображения' title='Настройки отображения'
@ -136,12 +169,11 @@ export function ToolbarOssGraph({
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', isIOS() ? '' : 'Правый клик')} titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')}
hideTitle={isContextMenuOpen} aria-label='Новый блок'
aria-label='Редактировать выбранную' icon={<IconConceptBlock size='1.25rem' className='icon-green' />}
icon={<IconEdit2 size='1.25rem' className='icon-primary' />} onClick={onCreateBlock}
onClick={handleEditItem} disabled={isProcessing}
disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')} titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
@ -151,13 +183,18 @@ export function ToolbarOssGraph({
disabled={isProcessing} disabled={isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новый блок', 'Ctrl + Shift + Q')} title='Активировать операцию'
aria-label='Новый блок' icon={<IconExecute size='1.25rem' className='icon-green' />}
icon={<IconConceptBlock size='1.25rem' className='icon-green' />} onClick={handleOperationExecute}
onClick={onCreateBlock} disabled={isProcessing || selectedItems.length !== 1 || !readyForSynthesis}
disabled={isProcessing} />
<MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')}
aria-label='Редактировать выбранную'
icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
aria-label='Удалить выбранную' aria-label='Удалить выбранную'

View File

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

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