Compare commits

..

37 Commits

Author SHA1 Message Date
Ivan
5c4149337b F: Add side panel for schema preview pt1
Some checks failed
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions
Backend CI / build (3.12) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
2025-07-02 20:15:27 +03:00
Ivan
ac38e9f4b5 B: Fix transparent bg for tabs 2025-07-02 15:55:16 +03:00
Ivan
1ee3e9ef4f F: Improve typography 2025-07-02 13:13:01 +03:00
Ivan
9b6a014fb3 R: Remove index files from components 2025-07-02 12:37:47 +03:00
Ivan
f365b20814 R: Refactor components to isolate reusable parts 2025-07-02 12:14:46 +03:00
Ivan
cb0f8783e5 B: Fix panel animation 2025-07-01 21:55:42 +03:00
Ivan
44c21dd7fd npm update 2025-07-01 20:14:04 +03:00
Ivan
65a65aba41 M: Replace functional typification label 2025-07-01 19:44:42 +03:00
Ivan
6b36a3fd41 F: Improve OSS UI 2025-07-01 16:13:24 +03:00
Ivan
976ab669e7 B: Tentative fix for longpress on iOS 2025-07-01 13:50:13 +03:00
Ivan
52b039259a R: Replace clsx with merging styles 2025-06-24 22:22:19 +03:00
Ivan
893875c0cf F: Add hide button for stats 2025-06-24 22:13:02 +03:00
Ivan
ef8355dc57 M: Simplify color tooltips 2025-06-24 14:25:27 +03:00
Ivan
8eedd70ffd Update index.css 2025-06-24 13:44:39 +03:00
Ivan
d2b37f9cad M: Add delay for some hover effects 2025-06-24 13:33:38 +03:00
Ivan
4a65e2a0c4 M: Improve text position in node 2025-06-21 16:58:40 +03:00
Ivan
ffebe27d48 B: Adjust selection for Constituenta editor 2025-06-19 18:12:34 +03:00
Ivan
807c22a3c8 M: Minor UI fixes 2025-06-19 17:49:51 +03:00
Ivan
a7f6f994fe F: Upgrade hover animations pt2 2025-06-19 13:12:48 +03:00
Ivan
fd6d448efd B: Fix UI for small screens 2025-06-19 10:57:28 +03:00
Ivan
59fa2c36de B: Fix dark theme loading 2025-06-19 00:16:02 +03:00
Ivan
e6d0b50833 M: Minor UI color improvements 2025-06-18 22:54:03 +03:00
Ivan
096efef357 F: Rework location picker 2025-06-18 22:30:55 +03:00
Ivan
8a3748bc2d F: Reworking colors and hovering pt1 2025-06-18 16:20:59 +03:00
Ivan
9b00125996 M: Add animation to details element 2025-06-18 10:15:03 +03:00
Ivan
4278505f86 M: Improve hover for footer 2025-06-18 09:25:37 +03:00
Ivan
983874af7e M: Improve hover effects on pagination 2025-06-18 00:39:54 +03:00
Ivan
d4e45a4c73 F: Improve placeholder for RSExpressions 2025-06-18 00:16:13 +03:00
Ivan
d75d834865 F: Improve hover buttons design 2025-06-17 22:28:37 +03:00
Ivan
cd26b0a10a M: Fix nav bar colors and transitions 2025-06-17 20:52:16 +03:00
Ivan
f78dbc6065 M: Always show search for RSList 2025-06-17 20:43:10 +03:00
Ivan
8cbb1faa50 npm update 2025-06-17 20:34:14 +03:00
Ivan
507a7c32db R: Migrating to cursor IDE 2025-06-17 19:46:45 +03:00
Ivan
be11b4a59a F: Improve layout when parent is changed 2025-06-12 19:43:43 +03:00
Ivan
af80099292 B: Fix initial data 2025-06-11 19:51:43 +03:00
Ivan
3e32233764 B: Fix node double click 2025-06-11 19:48:20 +03:00
Ivan
3d9930c44f R: Refactor layout data structure 2025-06-11 16:38:56 +03:00
170 changed files with 3776 additions and 2424 deletions

73
.cursorignore Normal file
View File

@ -0,0 +1,73 @@
# 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,8 +68,6 @@ class TestLibraryViewset(EndpointTester):
self.assertEqual(response.data['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible'])
self.assertEqual(response.data['read_only'], data['read_only'])
self.assertEqual(oss.layout().data['operations'], [])
self.assertEqual(oss.layout().data['blocks'], [])
self.logout()
data = {'title': 'Title2'}

View File

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

View File

@ -0,0 +1,41 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,72 @@
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

@ -0,0 +1,195 @@
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

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

View File

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

View File

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

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