Compare commits

...

37 Commits

Author SHA1 Message Date
Ivan
e2ab676ec2 B: Fix boolean envelopement
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled
2025-05-14 10:46:51 +03:00
Ivan
822533f293 Update schemas-guide.tsx 2025-05-13 18:42:30 +03:00
Ivan
5cba240d71 npm update 2025-05-13 18:28:55 +03:00
Ivan
1f5cad5073 Update application-layout.tsx 2025-05-13 18:17:31 +03:00
Ivan
11d0ec6fac B: Fix widescreen issue 2025-05-13 18:16:25 +03:00
Ivan
0e32c70610 R: Add ReadOnly wrapper for backend data 2025-04-30 12:31:07 +03:00
Ivan
687aa55dcb R: Refactor const global objects 2025-04-30 01:10:01 +03:00
Ivan
7f0c0fd70e F: Improve diagram flow management 2025-04-30 01:03:54 +03:00
Ivan
bac6650301 M: Improve searchbar hitboxes 2025-04-29 21:42:18 +03:00
Ivan
7496389c31 B: Prevent cyclic dependencies 2025-04-29 21:30:54 +03:00
Ivan
34893818fa F: Add space mode to ossFlow 2025-04-29 14:29:10 +03:00
Ivan
290081ed35 M: Improve transitions and remounting 2025-04-29 13:33:26 +03:00
Ivan
d786154374 B: Deep copy instead of modifying cache 2025-04-29 13:10:31 +03:00
Ivan
f820d84cb0 R: Improve ossFlow structure 2025-04-29 13:03:09 +03:00
Ivan
12028471bd R: Refactor layout management 2025-04-28 13:57:39 +03:00
Ivan
3d77317347 R: Remove unnecessary rerenders from useEffect 2025-04-28 11:38:31 +03:00
Ivan
d40e1ea256 npm update 2025-04-28 11:36:37 +03:00
Ivan
a3241ecff7 F: Improve oss UI 2025-04-24 14:55:15 +03:00
Ivan
5f049a929d F: Implement drag change hierarchy 2025-04-23 23:31:52 +03:00
Ivan
9f5fe24ad6 F: Implement node dragging behavior 2025-04-23 21:24:01 +03:00
Ivan
6b68375b01 F: Improve node UI context menu 2025-04-22 22:10:41 +03:00
Ivan
3d81a7dc28 M: Allow partial attribute updates 2025-04-22 14:23:26 +03:00
Ivan
58040f593f F: Implement edit block 2025-04-22 14:15:02 +03:00
Ivan
070ab18231 M: Improve typification labeling 2025-04-22 11:32:35 +03:00
Ivan
5dafe0a3e7 B: Fix react-flow styling 2025-04-22 00:52:17 +03:00
Ivan
c9c1d985b6 npm update 2025-04-22 00:42:50 +03:00
Ivan
a78a594509 F: Implementing block UI pt2 2025-04-22 00:40:43 +03:00
Ivan
13914a04f9 F: Implementing block UI pt1 2025-04-21 20:35:40 +03:00
Ivan
2ae9576384 F: Implement frontend api calls 2025-04-20 18:06:18 +03:00
Ivan
c07bdfbb3a R: Unify naming convention for frontend APIs 2025-04-20 16:20:43 +03:00
Ivan
6783300339 F: Implementing block UI pt1 2025-04-20 15:54:05 +03:00
Ivan
da035478f6 F: UI semantic styling improvements 2025-04-17 14:37:47 +03:00
Ivan
9df4c6799d F: Add LLM prompt generator 2025-04-16 21:05:54 +03:00
Ivan
5f767c943d npm update 2025-04-16 15:26:06 +03:00
Ivan
6cb0fd71ba F: Improve color and animation styling 2025-04-16 15:22:04 +03:00
Ivan
09071a6e8f F: Implement delete-block and update-block backend 2025-04-16 11:18:27 +03:00
Ivan
40314dbb63 B: Fix library updates 2025-04-15 13:16:24 +03:00
243 changed files with 6614 additions and 2906 deletions

View File

@ -6,11 +6,11 @@
"fractalbrew.backticks",
"streetsidesoftware.code-spell-checker",
"streetsidesoftware.code-spell-checker-russian",
"kamikillerto.vscode-colorize",
"batisteo.vscode-django",
"ms-azuretools.vscode-docker",
"dbaeumer.vscode-eslint",
"seyyedkhandon.firacode",
"nize.oklch-preview",
"ms-python.isort",
"ms-vscode.powershell",
"esbenp.prettier-vscode",

View File

@ -85,7 +85,7 @@ This readme file is used mostly to document project dependencies and conventions
<summary>VS Code plugins</summary>
<pre>
- ESLint
- Colorize
- Oklch Color Preview
- Tailwind CSS IntelliSense
- Code Spell Checker (eng + rus)
- Backticks

View File

@ -15,6 +15,7 @@ User profile:
- Profile pictures
- Custom LibraryItem lists
- Custom user filters and sharing filters
- Personal prompt templates
- Static analyzer for RSForm as a whole: check term duplication and empty conventions
- OSS clone and versioning

View File

@ -92,26 +92,26 @@ class OperationSchema:
)
def update_layout(self, data: dict) -> None:
''' Update positions. '''
''' Update graphical layout. '''
layout = self.layout()
layout.data = data
layout.save()
def create_operation(self, **kwargs) -> Operation:
''' Insert new operation. '''
''' Create Operation. '''
result = Operation.objects.create(oss=self.model, **kwargs)
self.cache.insert_operation(result)
self.save(update_fields=['time_update'])
return result
def create_block(self, **kwargs) -> Block:
''' Insert new block. '''
''' Create Block. '''
result = Block.objects.create(oss=self.model, **kwargs)
self.save(update_fields=['time_update'])
return result
def delete_operation(self, target: int, keep_constituents: bool = False):
''' Delete operation. '''
''' Delete Operation. '''
self.cache.ensure_loaded()
operation = self.cache.operation_by_id[target]
schema = self.cache.get_schema(operation)
@ -139,6 +139,20 @@ class OperationSchema:
operation.delete()
self.save(update_fields=['time_update'])
def delete_block(self, target: Block):
''' Delete Block. '''
new_parent = target.parent
if new_parent is not None:
for block in Block.objects.filter(parent=target):
if block != new_parent:
block.parent = new_parent
block.save(update_fields=['parent'])
for operation in Operation.objects.filter(parent=target):
operation.parent = new_parent
operation.save(update_fields=['parent'])
target.delete()
self.save(update_fields=['time_update'])
def set_input(self, target: int, schema: Optional[LibraryItem]) -> None:
''' Set input schema for operation. '''
operation = self.cache.operation_by_id[target]
@ -165,7 +179,7 @@ class OperationSchema:
self.save(update_fields=['time_update'])
def set_arguments(self, target: int, arguments: list[Operation]) -> None:
''' Set arguments to operation. '''
''' Set arguments of target Operation. '''
self.cache.ensure_loaded()
operation = self.cache.operation_by_id[target]
processed: list[Operation] = []
@ -198,7 +212,7 @@ class OperationSchema:
self.save(update_fields=['time_update'])
def set_substitutions(self, target: int, substitutes: list[dict]) -> None:
''' Clear all arguments for operation. '''
''' Clear all arguments for target Operation. '''
self.cache.ensure_loaded()
operation = self.cache.operation_by_id[target]
schema = self.cache.get_schema(operation)
@ -237,7 +251,7 @@ class OperationSchema:
self.save(update_fields=['time_update'])
def create_input(self, operation: Operation) -> RSForm:
''' Create input RSForm. '''
''' Create input RSForm for given Operation. '''
schema = RSForm.create(
owner=self.model.owner,
alias=operation.alias,
@ -254,7 +268,7 @@ class OperationSchema:
return schema
def execute_operation(self, operation: Operation) -> bool:
''' Execute target operation. '''
''' Execute target Operation. '''
schemas = [
arg.argument.result
for arg in operation.getQ_arguments().order_by('order')
@ -301,7 +315,7 @@ class OperationSchema:
return True
def relocate_down(self, source: RSForm, destination: RSForm, items: list[Constituenta]):
''' Move list of constituents to specific schema inheritor. '''
''' Move list of Constituents to destination Schema inheritor. '''
self.cache.ensure_loaded()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
@ -315,7 +329,7 @@ class OperationSchema:
Inheritance.objects.filter(operation_id=operation.pk, parent__in=items).delete()
def relocate_up(self, source: RSForm, destination: RSForm, items: list[Constituenta]) -> list[Constituenta]:
''' Move list of constituents to specific schema upstream. '''
''' Move list of Constituents upstream to destination Schema. '''
self.cache.ensure_loaded()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
@ -345,7 +359,7 @@ class OperationSchema:
cst_list: list[Constituenta],
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
''' Trigger cascade resolutions when new Constituenta is created. '''
self.cache.insert_schema(source)
inserted_aliases = [cst.alias for cst in cst_list]
depend_aliases: set[str] = set()
@ -361,13 +375,13 @@ class OperationSchema:
self._cascade_inherit_cst(operation.pk, source, cst_list, alias_mapping, exclude)
def after_change_cst_type(self, source: RSForm, target: Constituenta) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
''' Trigger cascade resolutions when Constituenta type is changed. '''
self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk)
self._cascade_change_cst_type(operation.pk, target.pk, cast(CstType, target.cst_type))
def after_update_cst(self, source: RSForm, target: Constituenta, data: dict, old_data: dict) -> None:
''' Trigger cascade resolutions when constituenta data is changed. '''
''' Trigger cascade resolutions when Constituenta data is changed. '''
self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk)
depend_aliases = self._extract_data_references(data, old_data)
@ -385,13 +399,13 @@ class OperationSchema:
)
def before_delete_cst(self, source: RSForm, target: list[Constituenta]) -> None:
''' Trigger cascade resolutions before constituents are deleted. '''
''' Trigger cascade resolutions before Constituents are deleted. '''
self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk)
self._cascade_delete_inherited(operation.pk, target)
def before_substitute(self, source: RSForm, substitutions: CstSubstitution) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
''' Trigger cascade resolutions before Constituents are substituted. '''
self.cache.insert_schema(source)
operation = self.cache.get_operation(source.model.pk)
self._cascade_before_substitute(substitutions, operation)

View File

@ -17,7 +17,7 @@ class PropagationFacade:
@staticmethod
def after_create_cst(source: RSForm, new_cst: list[Constituenta], exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
''' Trigger cascade resolutions when new constituenta is created. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
if exclude is None or host.pk not in exclude:

View File

@ -3,20 +3,23 @@
from .basics import LayoutSerializer, SubstitutionExSerializer
from .data_access import (
ArgumentSerializer,
BlockCreateSerializer,
BlockSerializer,
OperationCreateSerializer,
OperationDeleteSerializer,
CreateBlockSerializer,
CreateOperationSerializer,
DeleteBlockSerializer,
DeleteOperationSerializer,
MoveItemsSerializer,
OperationSchemaSerializer,
OperationSerializer,
OperationTargetSerializer,
OperationUpdateSerializer,
RelocateConstituentsSerializer,
SetOperationInputSerializer
SetOperationInputSerializer,
TargetOperationSerializer,
UpdateBlockSerializer,
UpdateOperationSerializer
)
from .responses import (
BlockCreatedResponse,
ConstituentaReferenceResponse,
NewBlockResponse,
NewOperationResponse,
NewSchemaResponse
OperationCreatedResponse,
SchemaCreatedResponse
)

View File

@ -1,4 +1,5 @@
''' Serializers for persistent data manipulation. '''
from collections import deque
from typing import cast
from django.db.models import F
@ -41,7 +42,7 @@ class ArgumentSerializer(serializers.ModelSerializer):
fields = ('operation', 'argument')
class BlockCreateSerializer(serializers.Serializer):
class CreateBlockSerializer(serializers.Serializer):
''' Serializer: Block creation. '''
class BlockCreateData(serializers.ModelSerializer):
''' Serializer: Block creation data. '''
@ -52,7 +53,6 @@ class BlockCreateSerializer(serializers.Serializer):
fields = 'title', 'description', 'parent'
layout = LayoutSerializer()
item_data = BlockCreateData()
width = serializers.FloatField()
height = serializers.FloatField()
@ -63,9 +63,10 @@ class BlockCreateSerializer(serializers.Serializer):
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and \
attrs['item_data']['parent'] is not None and \
attrs['item_data']['parent'].oss_id != oss.pk:
parent = attrs['item_data'].get('parent')
children_blocks = attrs.get('children_blocks', [])
if parent is not None and parent.oss_id != oss.pk:
raise serializers.ValidationError({
'parent': msg.parentNotInOSS()
})
@ -76,17 +77,114 @@ class BlockCreateSerializer(serializers.Serializer):
'children_operations': msg.childNotInOSS()
})
for block in attrs['children_blocks']:
for block in children_blocks:
if block.oss_id != oss.pk:
raise serializers.ValidationError({
'children_blocks': msg.childNotInOSS()
})
if parent:
descendant_ids = _collect_descendants(children_blocks)
if parent.pk in descendant_ids:
raise serializers.ValidationError({'parent': msg.blockCyclicHierarchy()})
return attrs
class OperationCreateSerializer(serializers.Serializer):
class UpdateBlockSerializer(serializers.Serializer):
''' Serializer: Block update. '''
class UpdateBlockData(serializers.ModelSerializer):
''' Serializer: Block update data. '''
class Meta:
''' serializer metadata. '''
model = Block
fields = 'title', 'description', 'parent'
layout = LayoutSerializer(required=False)
target = PKField(many=False, queryset=Block.objects.all())
item_data = UpdateBlockData()
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
block = cast(Block, attrs['target'])
if block.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.blockNotInOSS()
})
parent = attrs['item_data'].get('parent')
if parent is not None:
if parent.oss_id != oss.pk:
raise serializers.ValidationError({
'parent': msg.parentNotInOSS()
})
if parent == attrs['target']:
raise serializers.ValidationError({
'parent': msg.blockCyclicHierarchy()
})
return attrs
class DeleteBlockSerializer(serializers.Serializer):
''' Serializer: Delete block. '''
layout = LayoutSerializer()
target = PKField(many=False, queryset=Block.objects.all().only('oss_id'))
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
block = cast(Block, attrs['target'])
if block.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.blockNotInOSS()
})
return attrs
class MoveItemsSerializer(serializers.Serializer):
''' Serializer: Move items to another parent. '''
layout = LayoutSerializer()
operations = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'parent'))
blocks = PKField(many=True, queryset=Block.objects.all().only('oss_id', 'parent'))
destination = PKField(many=False, queryset=Block.objects.all().only('oss_id'), allow_null=True)
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
parent_block = cast(Block, attrs['destination'])
moved_blocks = attrs.get('blocks', [])
moved_operations = attrs.get('operations', [])
if parent_block is not None and parent_block.oss_id != oss.pk:
raise serializers.ValidationError({
'destination': msg.blockNotInOSS()
})
for operation in moved_operations:
if operation.oss_id != oss.pk:
raise serializers.ValidationError({
'operations': msg.operationNotInOSS()
})
for block in moved_blocks:
if parent_block is not None and block.pk == parent_block.pk:
raise serializers.ValidationError({
'destination': msg.blockCyclicHierarchy()
})
if block.oss_id != oss.pk:
raise serializers.ValidationError({
'blocks': msg.blockNotInOSS()
})
if parent_block:
ancestor_ids = _collect_ancestors(parent_block)
moved_block_ids = {b.pk for b in moved_blocks}
if moved_block_ids & ancestor_ids:
raise serializers.ValidationError({
'destination': msg.blockCyclicHierarchy()
})
return attrs
class CreateOperationSerializer(serializers.Serializer):
''' Serializer: Operation creation. '''
class OperationCreateData(serializers.ModelSerializer):
class CreateOperationData(serializers.ModelSerializer):
''' Serializer: Operation creation data. '''
alias = serializers.CharField()
operation_type = serializers.ChoiceField(OperationType.choices)
@ -99,8 +197,7 @@ class OperationCreateSerializer(serializers.Serializer):
'description', 'result', 'parent'
layout = LayoutSerializer()
item_data = OperationCreateData()
item_data = CreateOperationData()
position_x = serializers.FloatField()
position_y = serializers.FloatField()
create_schema = serializers.BooleanField(default=False, required=False)
@ -108,9 +205,8 @@ class OperationCreateSerializer(serializers.Serializer):
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and \
attrs['item_data']['parent'] is not None and \
attrs['item_data']['parent'].oss_id != oss.pk:
parent = attrs['item_data'].get('parent')
if parent is not None and parent.oss_id != oss.pk:
raise serializers.ValidationError({
'parent': msg.parentNotInOSS()
})
@ -120,23 +216,23 @@ class OperationCreateSerializer(serializers.Serializer):
for operation in attrs['arguments']:
if operation.oss_id != oss.pk:
raise serializers.ValidationError({
'arguments': msg.operationNotInOSS(oss.title)
'arguments': msg.operationNotInOSS()
})
return attrs
class OperationUpdateSerializer(serializers.Serializer):
class UpdateOperationSerializer(serializers.Serializer):
''' Serializer: Operation update. '''
class OperationUpdateData(serializers.ModelSerializer):
class UpdateOperationData(serializers.ModelSerializer):
''' Serializer: Operation update data. '''
class Meta:
''' serializer metadata. '''
model = Operation
fields = 'alias', 'title', 'description'
fields = 'alias', 'title', 'description', 'parent'
layout = LayoutSerializer()
layout = LayoutSerializer(required=False)
target = PKField(many=False, queryset=Operation.objects.all())
item_data = OperationUpdateData()
item_data = UpdateOperationData()
arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
substitutions = serializers.ListField(
child=SubstitutionSerializerBase(),
@ -145,7 +241,14 @@ class OperationUpdateSerializer(serializers.Serializer):
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and attrs['item_data']['parent'].oss_id != oss.pk:
parent = attrs['item_data'].get('parent')
target = cast(Block, attrs['target'])
if target.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.operationNotInOSS()
})
if parent is not None and parent.oss_id != oss.pk:
raise serializers.ValidationError({
'parent': msg.parentNotInOSS()
})
@ -160,7 +263,7 @@ class OperationUpdateSerializer(serializers.Serializer):
for operation in attrs['arguments']:
if operation.oss_id != oss.pk:
raise serializers.ValidationError({
'arguments': msg.operationNotInOSS(oss.title)
'arguments': msg.operationNotInOSS()
})
if 'substitutions' not in attrs:
@ -192,22 +295,7 @@ class OperationUpdateSerializer(serializers.Serializer):
return attrs
class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target'])
if oss and operation.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title)
})
return attrs
class OperationDeleteSerializer(serializers.Serializer):
class DeleteOperationSerializer(serializers.Serializer):
''' Serializer: Delete operation. '''
layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result'))
@ -217,9 +305,24 @@ class OperationDeleteSerializer(serializers.Serializer):
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target'])
if oss and operation.oss_id != oss.pk:
if operation.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title)
'target': msg.operationNotInOSS()
})
return attrs
class TargetOperationSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target'])
if operation.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.operationNotInOSS()
})
return attrs
@ -240,7 +343,7 @@ class SetOperationInputSerializer(serializers.Serializer):
operation = cast(Operation, attrs['target'])
if oss and operation.oss_id != oss.pk:
raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title)
'target': msg.operationNotInOSS()
})
if operation.operation_type != OperationType.INPUT:
raise serializers.ValidationError({
@ -355,3 +458,29 @@ class RelocateConstituentsSerializer(serializers.Serializer):
})
return attrs
# ====== Internals =================================================================================
def _collect_descendants(start_blocks: list[Block]) -> set[int]:
""" Recursively collect all descendant block IDs from a list of blocks. """
visited = set()
queue = deque(start_blocks)
while queue:
block = queue.popleft()
if block.pk not in visited:
visited.add(block.pk)
queue.extend(block.as_child_block.all())
return visited
def _collect_ancestors(block: Block) -> set[int]:
""" Recursively collect all ancestor block IDs of a block. """
ancestors = set()
current = block.parent
while current:
if current.pk in ancestors:
break # Prevent infinite loop in malformed data
ancestors.add(current.pk)
current = current.parent
return ancestors

View File

@ -6,19 +6,19 @@ from apps.library.serializers import LibraryItemSerializer
from .data_access import BlockSerializer, OperationSchemaSerializer, OperationSerializer
class NewOperationResponse(serializers.Serializer):
class OperationCreatedResponse(serializers.Serializer):
''' Serializer: Create operation response. '''
new_operation = OperationSerializer()
oss = OperationSchemaSerializer()
class NewBlockResponse(serializers.Serializer):
class BlockCreatedResponse(serializers.Serializer):
''' Serializer: Create block response. '''
new_block = BlockSerializer()
oss = OperationSchemaSerializer()
class NewSchemaResponse(serializers.Serializer):
class SchemaCreatedResponse(serializers.Serializer):
''' Serializer: Create RSForm for input operation response. '''
new_schema = LibraryItemSerializer()
oss = OperationSchemaSerializer()

View File

@ -27,6 +27,10 @@ class TestOssBlocks(EndpointTester):
self.block1 = self.owned.create_block(
title='1',
)
self.block2 = self.owned.create_block(
title='2',
parent=self.block1
)
self.operation1 = self.owned.create_operation(
alias='1',
operation_type=OperationType.INPUT,
@ -35,15 +39,12 @@ class TestOssBlocks(EndpointTester):
self.operation2 = self.owned.create_operation(
alias='2',
operation_type=OperationType.INPUT,
parent=self.block2,
)
self.operation3 = self.unowned.create_operation(
alias='3',
operation_type=OperationType.INPUT
)
self.block2 = self.owned.create_block(
title='2',
parent=self.block1
)
self.block3 = self.unowned.create_block(
title='3',
parent=self.block1
@ -165,3 +166,99 @@ class TestOssBlocks(EndpointTester):
self.block1.refresh_from_db()
self.assertEqual(self.operation1.parent.pk, new_block['id'])
self.assertEqual(self.block1.parent.pk, new_block['id'])
@decl_endpoint('/api/oss/{item}/create-block', method='post')
def test_create_block_cyclic(self):
self.populateData()
data = {
'item_data': {
'title': 'Test title',
'description': 'Тест кириллицы',
'parent': self.block2.pk
},
'layout': self.layout_data,
'position_x': 1337,
'position_y': 1337,
'width': 0.42,
'height': 0.42,
'children_operations': [],
'children_blocks': [self.block1.pk]
}
self.executeBadData(data=data, item=self.owned_id)
data['item_data']['parent'] = self.block1.pk
self.executeBadData(data=data)
data['children_blocks'] = [self.block2.pk]
self.executeCreated(data=data)
@decl_endpoint('/api/oss/{item}/delete-block', method='patch')
def test_delete_block(self):
self.populateData()
self.executeNotFound(item=self.invalid_id)
self.executeBadData(item=self.owned_id)
data = {
'layout': self.layout_data
}
self.executeBadData(data=data)
data['target'] = self.operation1.pk
self.executeBadData(data=data)
data['target'] = self.block3.pk
self.executeBadData(data=data)
data['target'] = self.block2.pk
self.logout()
self.executeForbidden(data=data)
self.login()
response = self.executeOK(data=data)
self.operation2.refresh_from_db()
self.assertEqual(len(response.data['blocks']), 1)
self.assertEqual(self.operation2.parent.pk, self.block1.pk)
data['target'] = self.block1.pk
response = self.executeOK(data=data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
self.assertEqual(len(response.data['blocks']), 0)
self.assertEqual(self.operation1.parent, None)
self.assertEqual(self.operation2.parent, None)
@decl_endpoint('/api/oss/{item}/update-block', method='patch')
def test_update_block(self):
self.populateData()
self.executeBadData(item=self.owned_id)
data = {
'target': self.invalid_id,
'item_data': {
'title': 'Test title mod',
'description': 'Comment mod',
'parent': None
},
}
self.executeBadData(data=data)
data['target'] = self.block3.pk
self.toggle_admin(True)
self.executeBadData(data=data)
data['target'] = self.block2.pk
self.logout()
self.executeForbidden(data=data)
self.login()
response = self.executeOK(data=data)
self.block2.refresh_from_db()
self.assertEqual(self.block2.title, data['item_data']['title'])
self.assertEqual(self.block2.description, data['item_data']['description'])
self.assertEqual(self.block2.parent, data['item_data']['parent'])
data['layout'] = self.layout_data
self.executeOK(data=data)

View File

@ -226,9 +226,8 @@ class TestOssOperations(EndpointTester):
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self):
self.executeNotFound(item=self.invalid_id)
self.populateData()
self.executeNotFound(item=self.invalid_id)
self.executeBadData(item=self.owned_id)
data = {
@ -371,7 +370,6 @@ class TestOssOperations(EndpointTester):
'title': 'Test title mod',
'description': 'Comment mod'
},
'layout': self.layout_data,
'arguments': [self.operation2.pk, self.operation1.pk],
'substitutions': [
{
@ -403,6 +401,10 @@ class TestOssOperations(EndpointTester):
self.assertEqual(sub.original.pk, data['substitutions'][0]['original'])
self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution'])
data['layout'] = self.layout_data
self.executeOK(data=data)
@decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_sync(self):
self.populateData()

View File

@ -55,12 +55,13 @@ class TestOssViewset(EndpointTester):
alias='3',
operation_type=OperationType.SYNTHESIS
)
layout = self.owned.layout()
layout.data = {'operations': [
self.layout_data = {'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0},
], 'blocks': []}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save()
self.owned.set_arguments(self.operation3.pk, [self.operation1, self.operation2])
@ -167,6 +168,61 @@ class TestOssViewset(EndpointTester):
self.assertEqual(response.data['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
@decl_endpoint('/api/oss/{item}/move-items', method='patch')
def test_move_items(self):
self.populateData()
self.executeBadData(item=self.owned_id)
block1 = self.owned.create_block(title='1')
block2 = self.owned.create_block(title='2')
data = {
'layout': self.layout_data,
'blocks': [block2.pk],
'operations': [self.operation1.pk, self.operation2.pk],
'destination': block2.pk
}
self.executeBadData(data=data)
data['destination'] = block1.pk
self.executeOK(data=data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
block2.refresh_from_db()
self.assertEqual(self.operation1.parent.pk, block1.pk)
self.assertEqual(self.operation2.parent.pk, block1.pk)
self.assertEqual(block2.parent.pk, block1.pk)
data['destination'] = None
self.executeOK(data=data)
self.operation1.refresh_from_db()
self.operation2.refresh_from_db()
block2.refresh_from_db()
self.assertEqual(block2.parent, None)
self.assertEqual(self.operation1.parent, None)
self.assertEqual(self.operation2.parent, None)
@decl_endpoint('/api/oss/{item}/move-items', method='patch')
def test_move_items_cyclic(self):
self.populateData()
self.executeBadData(item=self.owned_id)
block1 = self.owned.create_block(title='1')
block2 = self.owned.create_block(title='2', parent=block1)
block3 = self.owned.create_block(title='3', parent=block2)
data = {
'layout': self.layout_data,
'blocks': [block1.pk],
'operations': [],
'destination': block3.pk
}
self.executeBadData(data=data)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents(self):
self.populateData()

View File

@ -37,12 +37,15 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
''' Determine permission class. '''
if self.action in [
'update_layout',
'create_operation',
'create_block',
'update_block',
'delete_block',
'move_items',
'create_operation',
'update_operation',
'delete_operation',
'create_input',
'set_input',
'update_operation',
'execute_operation',
'relocate_constituents'
]:
@ -91,12 +94,168 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='create block',
tags=['OSS'],
request=s.CreateBlockSerializer(),
responses={
c.HTTP_201_CREATED: s.BlockCreatedResponse,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='create-block')
def create_block(self, request: Request, pk) -> HttpResponse:
''' Create Block. '''
serializer = s.CreateBlockSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
oss = m.OperationSchema(self.get_object())
layout = serializer.validated_data['layout']
children_blocks: list[m.Block] = serializer.validated_data['children_blocks']
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,
'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
'height': serializer.validated_data['height'],
})
oss.update_layout(layout)
if len(children_blocks) > 0:
for block in children_blocks:
block.parent = new_block
m.Block.objects.bulk_update(children_blocks, ['parent'])
if len(children_operations) > 0:
for operation in children_operations:
operation.parent = new_block
m.Operation.objects.bulk_update(children_operations, ['parent'])
return Response(
status=c.HTTP_201_CREATED,
data={
'new_block': s.BlockSerializer(new_block).data,
'oss': s.OperationSchemaSerializer(oss.model).data
}
)
@extend_schema(
summary='update block',
tags=['OSS'],
request=s.UpdateBlockSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='update-block')
def update_block(self, request: Request, pk) -> HttpResponse:
''' Update Block. '''
serializer = s.UpdateBlockSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
block: m.Block = cast(m.Block, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
if 'layout' in serializer.validated_data:
oss.update_layout(serializer.validated_data['layout'])
if 'title' in serializer.validated_data['item_data']:
block.title = serializer.validated_data['item_data']['title']
if 'description' in serializer.validated_data['item_data']:
block.description = serializer.validated_data['item_data']['description']
if 'parent' in serializer.validated_data['item_data']:
block.parent = serializer.validated_data['item_data']['parent']
block.save(update_fields=['title', 'description', 'parent'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='delete block',
tags=['OSS'],
request=s.DeleteBlockSerializer,
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='delete-block')
def delete_block(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete Block. '''
serializer = s.DeleteBlockSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
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]
with transaction.atomic():
oss.delete_block(block)
oss.update_layout(layout)
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='move items',
tags=['OSS'],
request=s.MoveItemsSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='move-items')
def move_items(self, request: Request, pk) -> HttpResponse:
''' Move items to another parent. '''
serializer = s.MoveItemsSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_layout(serializer.validated_data['layout'])
for operation in serializer.validated_data['operations']:
operation.parent = serializer.validated_data['destination']
operation.save(update_fields=['parent'])
for block in serializer.validated_data['blocks']:
block.parent = serializer.validated_data['destination']
block.save(update_fields=['parent'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='create operation',
tags=['OSS'],
request=s.OperationCreateSerializer(),
request=s.CreateOperationSerializer(),
responses={
c.HTTP_201_CREATED: s.NewOperationResponse,
c.HTTP_201_CREATED: s.OperationCreatedResponse,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
@ -104,8 +263,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
)
@action(detail=True, methods=['post'], url_path='create-operation')
def create_operation(self, request: Request, pk) -> HttpResponse:
''' Create new operation. '''
serializer = s.OperationCreateSerializer(
''' Create Operation. '''
serializer = s.CreateOperationSerializer(
data=request.data,
context={'oss': self.get_object()}
)
@ -153,60 +312,58 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
)
@extend_schema(
summary='create block',
summary='update operation',
tags=['OSS'],
request=s.BlockCreateSerializer(),
request=s.UpdateOperationSerializer(),
responses={
c.HTTP_201_CREATED: s.NewBlockResponse,
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['post'], url_path='create-block')
def create_block(self, request: Request, pk) -> HttpResponse:
''' Create new block. '''
serializer = s.BlockCreateSerializer(
@action(detail=True, methods=['patch'], url_path='update-operation')
def update_operation(self, request: Request, pk) -> HttpResponse:
''' Update Operation arguments and parameters. '''
serializer = s.UpdateOperationSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object())
layout = serializer.validated_data['layout']
children_blocks: list[m.Block] = serializer.validated_data['children_blocks']
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,
'x': serializer.validated_data['position_x'],
'y': serializer.validated_data['position_y'],
'width': serializer.validated_data['width'],
'height': serializer.validated_data['height'],
})
oss.update_layout(layout)
if len(children_blocks) > 0:
for block in children_blocks:
block.parent = new_block
m.Block.objects.bulk_update(children_blocks, ['parent'])
if len(children_operations) > 0:
for operation in children_operations:
operation.parent = new_block
m.Operation.objects.bulk_update(children_operations, ['parent'])
if 'layout' in serializer.validated_data:
oss.update_layout(serializer.validated_data['layout'])
if 'alias' in serializer.validated_data['item_data']:
operation.alias = serializer.validated_data['item_data']['alias']
if 'title' in serializer.validated_data['item_data']:
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 operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result)
if can_edit or operation.operation_type == m.OperationType.SYNTHESIS:
operation.result.alias = operation.alias
operation.result.title = operation.title
operation.result.description = operation.description
operation.result.save()
if 'arguments' in serializer.validated_data:
oss.set_arguments(operation.pk, serializer.validated_data['arguments'])
if 'substitutions' in serializer.validated_data:
oss.set_substitutions(operation.pk, serializer.validated_data['substitutions'])
return Response(
status=c.HTTP_201_CREATED,
data={
'new_block': s.BlockSerializer(new_block).data,
'oss': s.OperationSchemaSerializer(oss.model).data
}
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='delete operation',
tags=['OSS'],
request=s.OperationDeleteSerializer,
request=s.DeleteOperationSerializer,
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
@ -216,8 +373,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
)
@action(detail=True, methods=['patch'], url_path='delete-operation')
def delete_operation(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete operation. '''
serializer = s.OperationDeleteSerializer(
''' Endpoint: Delete Operation. '''
serializer = s.DeleteOperationSerializer(
data=request.data,
context={'oss': self.get_object()}
)
@ -246,9 +403,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@extend_schema(
summary='create input schema for target operation',
tags=['OSS'],
request=s.OperationTargetSerializer(),
request=s.TargetOperationSerializer(),
responses={
c.HTTP_200_OK: s.NewSchemaResponse,
c.HTTP_200_OK: s.SchemaCreatedResponse,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
@ -256,8 +413,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
)
@action(detail=True, methods=['patch'], url_path='create-input')
def create_input(self, request: Request, pk) -> HttpResponse:
''' Create new input RSForm. '''
serializer = s.OperationTargetSerializer(
''' Create input RSForm. '''
serializer = s.TargetOperationSerializer(
data=request.data,
context={'oss': self.get_object()}
)
@ -333,55 +490,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='update operation',
tags=['OSS'],
request=s.OperationUpdateSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='update-operation')
def update_operation(self, request: Request, pk) -> HttpResponse:
''' Update operation arguments and parameters. '''
serializer = s.OperationUpdateSerializer(
data=request.data,
context={'oss': self.get_object()}
)
serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object())
with transaction.atomic():
oss.update_layout(serializer.validated_data['layout'])
operation.alias = serializer.validated_data['item_data']['alias']
operation.title = serializer.validated_data['item_data']['title']
operation.description = serializer.validated_data['item_data']['description']
operation.save(update_fields=['alias', 'title', 'description'])
if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result)
if can_edit or operation.operation_type == m.OperationType.SYNTHESIS:
operation.result.alias = operation.alias
operation.result.title = operation.title
operation.result.description = operation.description
operation.result.save()
if 'arguments' in serializer.validated_data:
oss.set_arguments(operation.pk, serializer.validated_data['arguments'])
if 'substitutions' in serializer.validated_data:
oss.set_substitutions(operation.pk, serializer.validated_data['substitutions'])
return Response(
status=c.HTTP_200_OK,
data=s.OperationSchemaSerializer(oss.model).data
)
@extend_schema(
summary='execute operation',
tags=['OSS'],
request=s.OperationTargetSerializer(),
request=s.TargetOperationSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
@ -392,7 +504,7 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@action(detail=True, methods=['post'], url_path='execute-operation')
def execute_operation(self, request: Request, pk) -> HttpResponse:
''' Execute operation. '''
serializer = s.OperationTargetSerializer(
serializer = s.TargetOperationSerializer(
data=request.data,
context={'oss': self.get_object()}
)

View File

@ -137,7 +137,7 @@ class RSForm:
return result
def create_cst(self, data: dict, insert_after: Optional[Constituenta] = None) -> Constituenta:
''' Create new cst from data. '''
''' Create constituenta from data. '''
if insert_after is None:
position = INSERT_LAST
else:

View File

@ -77,7 +77,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@action(detail=True, methods=['post'], url_path='create-cst')
def create_cst(self, request: Request, pk) -> HttpResponse:
''' Create new constituenta. '''
''' Create Constituenta. '''
serializer = s.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
@ -254,7 +254,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@action(detail=True, methods=['patch'], url_path='delete-multiple-cst')
def delete_multiple_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete multiple constituents. '''
''' Endpoint: Delete multiple Constituents. '''
model = self._get_item()
serializer = s.CstListSerializer(
data=request.data,
@ -284,7 +284,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@action(detail=True, methods=['patch'], url_path='move-cst')
def move_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Move multiple constituents. '''
''' Endpoint: Move multiple Constituents. '''
model = self._get_item()
serializer = s.CstMoveSerializer(
data=request.data,
@ -334,7 +334,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@action(detail=True, methods=['patch'], url_path='restore-order')
def restore_order(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Restore order based on types and term graph. '''
''' Endpoint: Restore order based on types and Term graph. '''
model = self._get_item()
m.RSForm(model).restore_order()
return Response(
@ -449,7 +449,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@action(detail=True, methods=['post'], url_path='check-constituenta')
def check_constituenta(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Check RSLang expression against schema context. '''
''' Endpoint: Check RSLang expression against Schema context. '''
serializer = s.ConstituentaCheckSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['definition_formal']
@ -474,7 +474,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
)
@action(detail=True, methods=['post'], url_path='resolve')
def resolve(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Resolve references in text against schema terms context. '''
''' Endpoint: Resolve references in text against Schema terms context. '''
serializer = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text']
@ -543,7 +543,7 @@ class TrsImportView(views.APIView):
@extend_schema(
summary='create new RSForm empty or from file',
summary='create RSForm empty or from file',
tags=['RSForm'],
request=LibraryItemSerializer,
responses={

View File

@ -7,15 +7,23 @@ def constituentaNotInRSform(title: str):
def constituentaNotFromOperation():
return f'Конституента не соответствую аргументам операции'
return 'Конституента не соответствую аргументам операции'
def operationNotInOSS(title: str):
return f'Операция не принадлежит ОСС: {title}'
def operationNotInOSS():
return 'Операция не принадлежит ОСС'
def blockNotInOSS():
return 'Блок не принадлежит ОСС'
def parentNotInOSS():
return f'Родительский блок не принадлежит ОСС'
return 'Родительский блок не принадлежит ОСС'
def blockCyclicHierarchy():
return 'Попытка создания циклического вложения'
def childNotInOSS():

File diff suppressed because it is too large Load Diff

View File

@ -17,70 +17,70 @@
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@lezer/lr": "^1.4.2",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.1.7",
"@tanstack/react-query": "^5.72.2",
"@tanstack/react-query-devtools": "^5.72.2",
"@tanstack/react-table": "^8.21.2",
"@uiw/codemirror-themes": "^4.23.10",
"@uiw/react-codemirror": "^4.23.10",
"axios": "^1.8.4",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-select": "^2.2.4",
"@tanstack/react-query": "^5.76.0",
"@tanstack/react-query-devtools": "^5.76.0",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"global": "^4.4.0",
"js-file-download": "^0.4.12",
"lucide-react": "^0.487.0",
"lucide-react": "^0.510.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.55.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.56.3",
"react-icons": "^5.5.0",
"react-intl": "^7.1.10",
"react-router": "^7.5.0",
"react-intl": "^7.1.11",
"react-router": "^7.6.0",
"react-scan": "^0.3.3",
"react-tabs": "^6.1.0",
"react-toastify": "^11.0.5",
"react-tooltip": "^5.28.1",
"react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.2.9",
"use-debounce": "^10.0.4",
"zod": "^3.24.2",
"zustand": "^5.0.3"
"zod": "^3.24.4",
"zustand": "^5.0.4"
},
"devDependencies": {
"@lezer/generator": "^1.7.3",
"@playwright/test": "^1.51.1",
"@tailwindcss/vite": "^4.1.3",
"@playwright/test": "^1.52.0",
"@tailwindcss/vite": "^4.1.6",
"@types/jest": "^29.5.14",
"@types/node": "^22.14.0",
"@types/react": "^19.1.1",
"@types/react-dom": "^19.1.2",
"@types/node": "^22.15.17",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.4",
"babel-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216",
"eslint": "^9.24.0",
"@vitejs/plugin-react": "^4.4.1",
"babel-plugin-react-compiler": "^19.1.0-rc.1",
"eslint": "^9.26.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216",
"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.0.0",
"globals": "^16.1.0",
"jest": "^29.7.0",
"stylelint": "^16.18.0",
"stylelint": "^16.19.1",
"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.1",
"ts-jest": "^29.3.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.29.1",
"vite": "^6.2.6"
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5"
},
"jest": {
"preset": "ts-jest",

View File

@ -9,7 +9,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { NavigationState } from './navigation/navigation-context';
import { Footer } from './footer';
import { GlobalDialogs } from './global-dialogs';
import { GlobalLoader } from './global-Loader';
import { GlobalLoader } from './global-loader';
import { ToasterThemed } from './global-toaster';
import { GlobalTooltips } from './global-tooltips';
import { MutationErrors } from './mutation-errors';
@ -28,6 +28,7 @@ export function ApplicationLayout() {
<div className='min-w-80 antialiased h-full max-w-480 mx-auto'>
<ToasterThemed
className={clsx('sm:text-[14px]/[20px] text-[12px]/[16px]', noNavigationAnimation ? 'mt-6' : 'mt-14')}
aria-label='Оповещения'
autoClose={3000}
draggable={false}
pauseOnFocusLoss={false}
@ -41,7 +42,7 @@ export function ApplicationLayout() {
<Navigation />
<div
className='overflow-x-auto max-w-[100vw]'
className='overflow-x-auto max-w-[100dvw]'
style={{ maxHeight: viewportHeight }}
inert={activeDialog !== null}
>

View File

@ -113,6 +113,21 @@ const DlgUploadRSForm = React.lazy(() =>
const DlgGraphParams = React.lazy(() =>
import('@/features/rsform/dialogs/dlg-graph-params').then(module => ({ default: module.DlgGraphParams }))
);
const DlgCreateBlock = React.lazy(() =>
import('@/features/oss/dialogs/dlg-create-block').then(module => ({
default: module.DlgCreateBlock
}))
);
const DlgEditBlock = React.lazy(() =>
import('@/features/oss/dialogs/dlg-edit-block').then(module => ({
default: module.DlgEditBlock
}))
);
const DlgOssSettings = React.lazy(() =>
import('@/features/oss/dialogs/dlg-oss-settings').then(module => ({
default: module.DlgOssSettings
}))
);
export const GlobalDialogs = () => {
const active = useDialogsStore(state => state.active);
@ -127,6 +142,10 @@ export const GlobalDialogs = () => {
return <DlgCreateCst />;
case DialogType.CREATE_OPERATION:
return <DlgCreateOperation />;
case DialogType.CREATE_BLOCK:
return <DlgCreateBlock />;
case DialogType.EDIT_BLOCK:
return <DlgEditBlock />;
case DialogType.DELETE_CONSTITUENTA:
return <DlgDeleteCst />;
case DialogType.EDIT_EDITORS:
@ -141,6 +160,8 @@ export const GlobalDialogs = () => {
return <DlgEditWordForms />;
case DialogType.INLINE_SYNTHESIS:
return <DlgInlineSynthesis />;
case DialogType.OSS_SETTINGS:
return <DlgOssSettings />;
case DialogType.SHOW_AST:
return <DlgShowAST />;
case DialogType.SHOW_TYPE_GRAPH:

View File

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

View File

@ -1,3 +1,5 @@
import clsx from 'clsx';
import { useMutationErrors } from '@/backend/use-mutation-errors';
import { Button } from '@/components/control';
import { DescribeError } from '@/components/info-error';
@ -20,9 +22,23 @@ export function MutationErrors() {
return (
<div className='cc-modal-wrapper'>
<ModalBackdrop onHide={resetErrors} />
<div className='z-pop px-10 py-3 flex flex-col items-center border rounded-xl bg-background' role='alertdialog'>
<div
className={clsx(
'z-pop', //
'flex flex-col px-10 py-3 items-center',
'border rounded-xl bg-background'
)}
role='alertdialog'
>
<h1 className='py-2 select-none'>Ошибка при обработке</h1>
<div className='px-3 flex flex-col text-destructive text-sm font-semibold select-text'>
<div
className={clsx(
'max-h-[calc(100svh-8rem)] max-w-[calc(100svw-2rem)]',
'px-3 flex flex-col',
'text-destructive text-sm font-semibold select-text',
'overflow-auto'
)}
>
<DescribeError error={mutationErrors[0]} />
</div>
<Button onClick={resetErrors} className='w-fit' text='Закрыть' />

View File

@ -34,7 +34,7 @@ export function Navigation() {
<div
className={clsx(
'pl-2 sm:pr-4 h-12 flex cc-shadow-border',
'transition-[max-height,translate] ease-bezier duration-(--duration-move)',
'transition-[max-height,translate] ease-bezier duration-move',
noNavigationAnimation ? '-translate-y-6 max-h-0' : 'max-h-12'
)}
>

View File

@ -4,9 +4,7 @@
import { buildConstants } from '@/utils/build-constants';
/**
* Routes.
*/
/** Routes. */
export const routes = {
not_found: 'not-found',
login: 'login',
@ -22,11 +20,9 @@ export const routes = {
oss: 'oss',
icons: 'icons',
database_schema: 'database-schema'
};
} as const;
/**
* Internal navigation URLs.
*/
/** Internal navigation URLs. */
export const urls = {
page404: '/not-found',
admin: `${buildConstants.backend}/admin`,
@ -66,4 +62,4 @@ export const urls = {
oss_props: ({ id, tab }: { id: number | string; tab: number }) => {
return `/oss/${id}?tab=${tab}`;
}
};
} as const;

View File

@ -8,6 +8,7 @@ import { type z, ZodError } from 'zod';
import { buildConstants } from '@/utils/build-constants';
import { PARAMETER } from '@/utils/constants';
import { errorMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { extractErrorMessage } from '@/utils/utils';
export { AxiosError } from 'axios';
@ -58,7 +59,7 @@ export function axiosGet<ResponseData>({ endpoint, options, schema }: IAxiosGetR
.get<ResponseData>(endpoint, options)
.then(response => {
schema?.parse(response.data);
return response.data;
return response.data as RO<ResponseData>;
})
.catch((error: Error | AxiosError) => {
// Note: Ignore cancellation errors
@ -81,7 +82,7 @@ export function axiosPost<RequestData, ResponseData = void>({
.then(response => {
schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage);
return response.data;
return response.data as RO<ResponseData>;
})
.catch((error: Error | AxiosError | ZodError) => {
notifyError(error);
@ -100,7 +101,7 @@ export function axiosDelete<RequestData, ResponseData = void>({
.then(response => {
schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage);
return response.data;
return response.data as RO<ResponseData>;
})
.catch((error: Error | AxiosError | ZodError) => {
notifyError(error);
@ -119,7 +120,7 @@ export function axiosPatch<RequestData, ResponseData = void>({
.then(response => {
schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage);
return response.data;
return response.data as RO<ResponseData>;
})
.catch((error: Error | AxiosError | ZodError) => {
notifyError(error);

View File

@ -6,7 +6,7 @@ export const DELAYS = {
staleShort: 5 * 60 * 1000,
staleMedium: 1 * 60 * 60 * 1000,
staleLong: 24 * 60 * 60 * 1000
};
} as const;
/** API keys for local cache. */
export const KEYS = {
@ -19,8 +19,8 @@ export const KEYS = {
global_mutation: 'global_mutation',
composite: {
libraryList: ['library', 'list'],
libraryList: ['library', 'list'] as const,
ossItem: ({ itemID }: { itemID?: number }) => [KEYS.oss, 'item', itemID],
rsItem: ({ itemID, version }: { itemID?: number; version?: number }) => [KEYS.rsform, 'item', itemID, version ?? '']
}
};
} as const;

View File

@ -19,19 +19,10 @@ import { PaginationTools } from './pagination-tools';
import { TableBody } from './table-body';
import { TableFooter } from './table-footer';
import { TableHeader } from './table-header';
import { useDataTable } from './use-data-table';
import { type IConditionalStyle, useDataTable } from './use-data-table';
export { createColumnHelper, type RowSelectionState, type VisibilityState };
/** Style to conditionally apply to rows. */
export interface IConditionalStyle<TData> {
/** Callback to determine if the style should be applied. */
when: (rowData: TData) => boolean;
/** Style to apply. */
style: React.CSSProperties;
}
export interface DataTableProps<TData extends RowData>
extends Styling,
Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> {
@ -87,7 +78,7 @@ export interface DataTableProps<TData extends RowData>
paginationPerPage?: number;
/** List of options to choose from for pagination. */
paginationOptions?: number[];
paginationOptions?: readonly number[];
/** Callback to be called when the pagination option is changed. */
onChangePaginationOption?: (newValue: number) => void;

View File

@ -1,7 +1,2 @@
export {
createColumnHelper,
DataTable,
type IConditionalStyle,
type RowSelectionState,
type VisibilityState
} from './data-table';
export { createColumnHelper, DataTable, type RowSelectionState, type VisibilityState } from './data-table';
export { type IConditionalStyle } from './use-data-table';

View File

@ -1,17 +1,16 @@
'use no memo';
'use client';
import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table';
import { prefixes } from '@/utils/constants';
import { IconPageFirst, IconPageLast, IconPageLeft, IconPageRight } from '../icons';
import { SelectPagination } from './select-pagination';
interface PaginationToolsProps<TData> {
id?: string;
table: Table<TData>;
paginationOptions: number[];
paginationOptions: readonly number[];
onChangePaginationOption?: (newValue: number) => void;
}
@ -21,15 +20,6 @@ export function PaginationTools<TData>({
onChangePaginationOption,
paginationOptions
}: PaginationToolsProps<TData>) {
const handlePaginationOptionsChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const perPage = Number(event.target.value);
table.setPageSize(perPage);
onChangePaginationOption?.(perPage);
},
[table, onChangePaginationOption]
);
return (
<div className='flex justify-end items-center my-2 text-sm cc-controls select-none'>
<span className='mr-3'>
@ -93,19 +83,12 @@ export function PaginationTools<TData>({
<IconPageLast size='1.5rem' />
</button>
</div>
<select
<SelectPagination
id={id ? `${id}__per_page` : undefined}
aria-label='Выбор количества строчек на странице'
value={table.getState().pagination.pageSize}
onChange={handlePaginationOptionsChange}
className='mx-2 cursor-pointer bg-transparent focus-outline'
>
{paginationOptions.map(pageSize => (
<option key={`${prefixes.page_size}${pageSize}`} value={pageSize} aria-label={`${pageSize} на страницу`}>
{pageSize} на стр
</option>
))}
</select>
table={table}
paginationOptions={paginationOptions}
onChange={onChangePaginationOption}
/>
</div>
);
}

View File

@ -0,0 +1,46 @@
'use no memo';
'use client';
import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table';
import { prefixes } from '@/utils/constants';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../input/select';
interface SelectPaginationProps<TData> {
id?: string;
table: Table<TData>;
paginationOptions: readonly number[];
onChange?: (newValue: number) => void;
}
export function SelectPagination<TData>({ id, table, paginationOptions, onChange }: SelectPaginationProps<TData>) {
const handlePaginationOptionsChange = useCallback(
(newValue: string) => {
const perPage = Number(newValue);
table.setPageSize(perPage);
onChange?.(perPage);
},
[table, onChange]
);
return (
<Select onValueChange={handlePaginationOptionsChange} defaultValue={String(table.getState().pagination.pageSize)}>
<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'
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{paginationOptions?.map(option => (
<SelectItem key={`${prefixes.page_size}${option}`} value={String(option)}>
{option} на стр
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -1,11 +1,11 @@
'use no memo';
'use client';
import { useCallback } from 'react';
import { type Cell, flexRender, type Row, type Table } from '@tanstack/react-table';
import clsx from 'clsx';
import { type Row, type Table } from '@tanstack/react-table';
import { SelectRow } from './select-row';
import { type IConditionalStyle } from '.';
import { TableRow } from './table-row';
import { type IConditionalStyle } from './use-data-table';
interface TableBodyProps<TData> {
table: Table<TData>;
@ -30,82 +30,43 @@ export function TableBody<TData>({
onRowClicked,
onRowDoubleClicked
}: TableBodyProps<TData>) {
const handleRowClicked = useCallback(
(target: Row<TData>, event: React.MouseEvent<Element>) => {
onRowClicked?.(target.original, event);
if (table.options.enableRowSelection && target.getCanSelect()) {
if (event.shiftKey && !!lastSelected && lastSelected !== target.id) {
const { rows, rowsById } = table.getRowModel();
const lastIndex = rowsById[lastSelected].index;
const currentIndex = target.index;
const toggleRows = rows.slice(
lastIndex > currentIndex ? currentIndex : lastIndex + 1,
lastIndex > currentIndex ? lastIndex : currentIndex + 1
);
const newSelection: Record<string, boolean> = {};
toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected();
});
table.setRowSelection(prev => ({ ...prev, ...newSelection }));
onChangeLastSelected(null);
} else {
onChangeLastSelected(target.id);
target.toggleSelected(!target.getIsSelected());
}
}
},
[table, lastSelected, onChangeLastSelected, onRowClicked]
const getRowStyles = useCallback(
(row: Row<TData>) =>
conditionalRowStyles
?.filter(item => !!item.style && item.when(row.original))
?.reduce((prev, item) => ({ ...prev, ...item.style }), {}),
[conditionalRowStyles]
);
const getRowStyles = useCallback(
const getRowClasses = useCallback(
(row: Row<TData>) => {
return {
...conditionalRowStyles!
.filter(item => item.when(row.original))
.reduce((prev, item) => ({ ...prev, ...item.style }), {})
};
return conditionalRowStyles
?.filter(item => !!item.className && item.when(row.original))
?.reduce((prev, item) => {
prev.push(item.className!);
return prev;
}, [] as string[]);
},
[conditionalRowStyles]
);
return (
<tbody>
{table.getRowModel().rows.map((row: Row<TData>, index) => (
<tr
{table.getRowModel().rows.map((row: Row<TData>) => (
<TableRow
key={row.id}
className={clsx(
'cc-scroll-row',
'cc-hover cc-animate-background duration-(--duration-fade)',
!noHeader && 'scroll-mt-[calc(2px+2rem)]',
table.options.enableRowSelection && row.getIsSelected()
? 'cc-selected'
: 'odd:bg-secondary even:bg-background'
)}
style={{ ...(conditionalRowStyles ? getRowStyles(row) : []) }}
onClick={event => handleRowClicked(row, event)}
onDoubleClick={event => onRowDoubleClicked?.(row.original, event)}
>
{table.options.enableRowSelection ? (
<td key={`select-${row.id}`} className='pl-2 border-y'>
<SelectRow row={row} onChangeLastSelected={onChangeLastSelected} />
</td>
) : null}
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
<td
key={cell.id}
className={clsx(
'px-2 align-middle border-y',
dense ? 'py-1' : 'py-2',
onRowClicked || onRowDoubleClicked ? 'cursor-pointer' : 'cursor-auto'
)}
style={{
width: noHeader && index === 0 ? `calc(var(--col-${cell.column.id}-size) * 1px)` : undefined
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
table={table}
row={row}
className={getRowClasses(row)?.join(' ')}
style={conditionalRowStyles ? { ...getRowStyles(row) } : undefined}
noHeader={noHeader}
dense={dense}
lastSelected={lastSelected}
onChangeLastSelected={onChangeLastSelected}
onRowClicked={onRowClicked}
onRowDoubleClicked={onRowDoubleClicked}
/>
))}
</tbody>
);

View File

@ -1,4 +1,5 @@
'use no memo';
'use client';
import { flexRender, type Header, type HeaderGroup, type Table } from '@tanstack/react-table';
import clsx from 'clsx';

View File

@ -0,0 +1,108 @@
'use no memo';
import { useCallback } from 'react';
import { type Cell, flexRender, type Row, type Table } from '@tanstack/react-table';
import clsx from 'clsx';
import { cn } from '../utils';
import { SelectRow } from './select-row';
interface TableRowProps<TData> {
table: Table<TData>;
row: Row<TData>;
className?: string;
style?: React.CSSProperties;
noHeader?: boolean;
dense?: boolean;
lastSelected: string | null;
onChangeLastSelected: (newValue: string | null) => void;
onRowClicked?: (rowData: TData, event: React.MouseEvent<Element>) => void;
onRowDoubleClicked?: (rowData: TData, event: React.MouseEvent<Element>) => void;
}
export function TableRow<TData>({
table,
row,
className,
style,
noHeader,
dense,
lastSelected,
onChangeLastSelected,
onRowClicked,
onRowDoubleClicked
}: TableRowProps<TData>) {
const hasBG = className?.includes('bg-') ?? false;
const handleRowClicked = useCallback(
(target: Row<TData>, event: React.MouseEvent<Element>) => {
onRowClicked?.(target.original, event);
if (table.options.enableRowSelection && target.getCanSelect()) {
if (event.shiftKey && !!lastSelected && lastSelected !== target.id) {
const { rows, rowsById } = table.getRowModel();
const lastIndex = rowsById[lastSelected].index;
const currentIndex = target.index;
const toggleRows = rows.slice(
lastIndex > currentIndex ? currentIndex : lastIndex + 1,
lastIndex > currentIndex ? lastIndex : currentIndex + 1
);
const newSelection: Record<string, boolean> = {};
toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected();
});
table.setRowSelection(prev => ({ ...prev, ...newSelection }));
onChangeLastSelected(null);
} else {
onChangeLastSelected(target.id);
target.toggleSelected(!target.getIsSelected());
}
}
},
[table, lastSelected, onChangeLastSelected, onRowClicked]
);
return (
<tr
className={cn(
'cc-scroll-row',
'cc-hover cc-animate-background duration-fade',
!noHeader && 'scroll-mt-[calc(2px+2rem)]',
table.options.enableRowSelection && row.getIsSelected()
? 'cc-selected'
: !hasBG
? 'odd:bg-secondary even:bg-background'
: '',
className
)}
style={style}
onClick={event => handleRowClicked(row, event)}
onDoubleClick={event => onRowDoubleClicked?.(row.original, event)}
>
{table.options.enableRowSelection ? (
<td key={`select-${row.id}`} className='pl-2 border-y'>
<SelectRow row={row} onChangeLastSelected={onChangeLastSelected} />
</td>
) : null}
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
<td
key={cell.id}
className={clsx(
'px-2 align-middle border-y',
dense ? 'py-1' : 'py-2',
onRowClicked || onRowDoubleClicked ? 'cursor-pointer' : 'cursor-auto'
)}
style={{
width: noHeader && row.index === 0 ? `calc(var(--col-${cell.column.id}-size) * 1px)` : undefined
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
}

View File

@ -21,7 +21,10 @@ export interface IConditionalStyle<TData> {
when: (rowData: TData) => boolean;
/** Style to apply. */
style: React.CSSProperties;
style?: React.CSSProperties;
/** Classname to apply. */
className?: string;
}
interface UseDataTableProps<TData extends RowData>

View File

@ -0,0 +1,100 @@
'use client';
import { type ReactNode, useState } from 'react';
import { Background, ReactFlow, type ReactFlowProps } from 'reactflow';
export { useReactFlow, useStoreApi } from 'reactflow';
import { cn } from '../utils';
type DiagramFlowProps = {
children?: ReactNode;
height?: number | string;
showGrid?: boolean;
gridSize?: number;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
onKeyUp?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
} & ReactFlowProps;
export function DiagramFlow({
children,
className,
style,
height,
showGrid = false,
gridSize = 20,
onKeyDown,
onKeyUp,
nodesDraggable = true,
nodesFocusable,
edgesFocusable,
onNodesChange,
onEdgesChange,
onNodeClick,
onNodeDoubleClick,
onNodeContextMenu,
onNodeMouseEnter,
onNodeMouseLeave,
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
onContextMenu,
...restProps
}: DiagramFlowProps) {
const [spaceMode, setSpaceMode] = useState(false);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Space') {
event.preventDefault();
event.stopPropagation();
setSpaceMode(true);
}
onKeyDown?.(event);
}
function handleKeyUp(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Space') {
setSpaceMode(false);
}
onKeyUp?.(event);
}
function handleContextMenu(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
onContextMenu?.(event);
}
return (
<div
tabIndex={-1}
className={cn('relative cc-mask-sides max-w-480 w-[100dvw]', spaceMode && 'space-mode', className)}
style={{ ...style, height: height }}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
>
<ReactFlow
{...restProps}
onNodesChange={spaceMode ? undefined : onNodesChange}
onEdgesChange={spaceMode ? undefined : onEdgesChange}
onNodeClick={spaceMode ? undefined : onNodeClick}
onNodeDoubleClick={spaceMode ? undefined : onNodeDoubleClick}
nodesDraggable={!spaceMode && nodesDraggable}
nodesFocusable={!spaceMode && nodesFocusable}
edgesFocusable={!spaceMode && edgesFocusable}
onNodeDragStart={spaceMode ? undefined : onNodeDragStart}
onNodeDrag={spaceMode ? undefined : onNodeDrag}
onNodeDragStop={spaceMode ? undefined : onNodeDragStop}
onNodeContextMenu={spaceMode ? undefined : onNodeContextMenu}
onNodeMouseEnter={spaceMode ? undefined : onNodeMouseEnter}
onNodeMouseLeave={spaceMode ? undefined : onNodeMouseLeave}
onContextMenu={handleContextMenu}
>
{showGrid ? <Background gap={gridSize} /> : null}
{children}
</ReactFlow>
</div>
);
}

View File

@ -10,12 +10,13 @@ export { BiCheck as IconAccept } from 'react-icons/bi';
export { BiX as IconRemove } from 'react-icons/bi';
export { BiTrash as IconDestroy } from 'react-icons/bi';
export { BiReset as IconReset } from 'react-icons/bi';
export { TbArrowsDiagonal2 as IconResize } from 'react-icons/tb';
export { LiaEdit as IconEdit } from 'react-icons/lia';
export { FiEdit as IconEdit2 } from 'react-icons/fi';
export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } from 'react-icons/bi';
export { BiCog as IconSettings } from 'react-icons/bi';
export { LuSettings as IconSettings } from 'react-icons/lu';
export { TbEye as IconShow } from 'react-icons/tb';
export { TbEyeX as IconHide } from 'react-icons/tb';
export { BiShareAlt as IconShare } from 'react-icons/bi';
@ -68,9 +69,12 @@ export { LuGlasses as IconReader } from 'react-icons/lu';
// ===== Domain entities =======
export { TbBriefcase as IconBusiness } from 'react-icons/tb';
export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { BiBot as IconRobot } from 'react-icons/bi';
export { TbGps as IconCoordinates } from 'react-icons/tb';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { TbHexagons as IconOSS } from 'react-icons/tb';
export { BiScreenshot as IconConceptBlock } from 'react-icons/bi';
export { TbHexagon as IconRSForm } from 'react-icons/tb';
export { TbAssembly as IconRSFormOwned } from 'react-icons/tb';
export { TbBallFootball as IconRSFormImported } from 'react-icons/tb';
@ -101,7 +105,7 @@ export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb';
export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi';
export { TbCar4Wd as IconTypeGraph } from 'react-icons/tb';
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 { RiLockLine as IconImmutable } from 'react-icons/ri';
@ -137,6 +141,7 @@ export { GrConnect as IconConnect } from 'react-icons/gr';
export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI =======
export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';
@ -200,7 +205,7 @@ export function IconLogin(props: IconProps) {
export function CheckboxChecked() {
return (
<svg className='w-4 h-4 p-0.75 -ml-0.25' viewBox='0 0 512 512' fill='#ffffff'>
<svg className='w-4 h-4 p-0.75 -ml-0.25 -mt-0.25' viewBox='0 0 512 512' fill='#ffffff'>
<path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' />
</svg>
);
@ -208,7 +213,7 @@ export function CheckboxChecked() {
export function CheckboxNull() {
return (
<svg className='w-4 h-4 px-0.25' viewBox='0 0 16 16' fill='#ffffff'>
<svg className='w-4 h-4 px-0.25 -ml-0.25 -mt-0.25' viewBox='0 0 16 16' fill='#ffffff'>
<path d='M2 7.75A.75.75 0 012.75 7h10a.75.75 0 010 1.5h-10A.75.75 0 012 7.75z' />
</svg>
);

View File

@ -78,8 +78,7 @@ export function InfoError({ error }: InfoErrorProps) {
return (
<div
className={clsx(
'cc-fade-in',
'min-w-100',
'min-w-100', //
'px-3 py-2 flex flex-col',
'text-destructive text-sm font-semibold',
'select-text'

View File

@ -14,10 +14,13 @@ export interface CheckboxProps extends Omit<Button, 'value' | 'onClick' | 'onCha
disabled?: boolean;
/** Current value - `true` or `false`. */
value?: boolean;
value: boolean;
/** Callback to set the `value`. */
onChange?: (newValue: boolean) => void;
/** Custom icon to display next instead of checkbox. */
customIcon?: (checked?: boolean) => React.ReactNode;
}
/**
@ -31,6 +34,7 @@ export function Checkbox({
hideTitle,
className,
value,
customIcon,
onChange,
...restProps
}: CheckboxProps) {
@ -63,6 +67,9 @@ export function Checkbox({
disabled={disabled}
{...restProps}
>
{customIcon ? (
customIcon(value)
) : (
<div
className={clsx(
'w-4 h-4', //
@ -72,6 +79,7 @@ export function Checkbox({
>
{value ? <CheckboxChecked /> : null}
</div>
)}
{label ? <span className={clsx('text-start text-sm whitespace-nowrap select-text', cursor)}>{label}</span> : null}
</button>
);

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { IconRemove } from '../icons';
@ -11,7 +11,7 @@ import { cn } from '../utils';
interface ComboBoxProps<Option> extends Styling {
id?: string;
items?: Option[];
items?: readonly Option[];
value: Option | null;
onChange: (newValue: Option | null) => void;
@ -49,11 +49,12 @@ export function ComboBox<Option>({
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (triggerRef.current) {
function handleOpenChange(isOpen: boolean) {
setOpen(isOpen);
if (isOpen && triggerRef.current) {
setPopoverWidth(triggerRef.current.offsetWidth);
}
}, [open]);
}
function handleChangeValue(newValue: Option | null) {
onChange(newValue);
@ -66,7 +67,7 @@ export function ComboBox<Option>({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
id={id}

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { IconRemove } from '../icons';
@ -43,11 +43,12 @@ export function ComboMulti<Option>({
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (triggerRef.current) {
function handleOpenChange(isOpen: boolean) {
setOpen(isOpen);
if (isOpen && triggerRef.current) {
setPopoverWidth(triggerRef.current.offsetWidth);
}
}, [open]);
}
function handleAddValue(newValue: Option) {
if (value.includes(newValue)) {
@ -70,7 +71,7 @@ export function ComboMulti<Option>({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
id={id}

View File

@ -47,7 +47,7 @@ export function SearchBar({
id={id}
type='search'
className={clsx(
'min-w-0 py-2',
'min-w-0 py-2 w-full pr-2',
'leading-tight truncate hover:text-clip',
'bg-transparent',
!noIcon && 'pl-8',

View File

@ -44,11 +44,12 @@ export function SelectTree<ItemType>({
...restProps
}: SelectTreeProps<ItemType>) {
const foldable = new Set(items.filter(item => getParent(item) !== item).map(item => getParent(item)));
const [folded, setFolded] = useState<ItemType[]>(items);
const defaultFolded = items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item);
const [folded, setFolded] = useState<ItemType[]>(defaultFolded);
useEffect(() => {
setFolded(items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item));
}, [value, getParent, items]);
setFolded(defaultFolded);
}, [defaultFolded]);
function onFoldItem(target: ItemType) {
setFolded(prev =>

View File

@ -1,7 +1,5 @@
'use client';
import { APP_COLORS } from '@/styling/colors';
interface LoaderProps {
/** Scale of the loader from 1 to 10. */
scale?: number;
@ -57,8 +55,8 @@ const animatePulse = (startBig: boolean, duration: string) => {
export function Loader({ scale = 5, circular }: LoaderProps) {
if (circular) {
return (
<div className='flex justify-center' aria-label='three-circles-loading' aria-busy='true' role='progressbar'>
<svg height={`${scale * 20}`} width={`${scale * 20}`} viewBox='0 0 100 100' fill={APP_COLORS.bgPrimary}>
<div className='flex justify-center text-primary' aria-busy='true' role='progressbar'>
<svg height={`${scale * 20}`} width={`${scale * 20}`} viewBox='0 0 100 100' fill='currentColor'>
<path d='M31.6,3.5C5.9,13.6-6.6,42.7,3.5,68.4c10.1,25.7,39.2,38.3,64.9,28.1l-3.1-7.9c-21.3,8.4-45.4-2-53.8-23.3 c-8.4-21.3,2-45.4,23.3-53.8L31.6,3.5z'>
{animateRotation('2.25s')}
</path>
@ -73,8 +71,8 @@ export function Loader({ scale = 5, circular }: LoaderProps) {
);
} else {
return (
<div className='flex justify-center' aria-busy='true' role='progressbar'>
<svg height={`${scale * 20}`} width={`${scale * 20}`} viewBox='0 0 120 30' fill={APP_COLORS.bgPrimary}>
<div className='flex justify-center text-primary' aria-busy='true' role='progressbar'>
<svg height={`${scale * 20}`} width={`${scale * 20}`} viewBox='0 0 120 30' fill='currentColor'>
<circle cx='15' cy='15' r='16'>
{animatePulse(true, '0.8s')}
</circle>

View File

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

View File

@ -61,4 +61,4 @@ export const authApi = {
endpoint: '/users/api/password-reset/confirm',
request: { data: data }
})
};
} as const;

View File

@ -15,7 +15,7 @@ export function ExpectedAnonymous() {
}
return (
<div className='cc-fade-in flex flex-col items-center gap-3 py-6'>
<div className='flex flex-col items-center gap-3 py-6'>
<p className='font-semibold'>{`Вы вошли в систему как ${user.username}`}</p>
<div className='flex gap-3'>
<TextURL text='Новая схема' href='/library/create' />

View File

@ -55,7 +55,7 @@ export function LoginPage() {
}
return (
<form
className='cc-column cc-fade-in w-96 mx-auto pt-12 pb-6 px-6'
className='cc-column w-96 mx-auto pt-12 pb-6 px-6'
onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
>

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { urls, useConceptNavigation } from '@/app';
@ -13,13 +13,26 @@ import { useQueryStrings } from '@/hooks/use-query-strings';
import { useResetPassword } from '../backend/use-reset-password';
function useTokenValidation(token: string, isPending: boolean) {
const { validateToken } = useResetPassword();
const [isTokenValidating, setIsTokenValidating] = useState(false);
const validate = async () => {
if (!isTokenValidating && !isPending) {
await validateToken({ token });
setIsTokenValidating(true);
}
};
return { isTokenValidating, validate };
}
export function Component() {
const router = useConceptNavigation();
const token = useQueryStrings().get('token') ?? '';
const { validateToken, resetPassword, isPending, error: serverError } = useResetPassword();
const { resetPassword, isPending, error: serverError } = useResetPassword();
const { isTokenValidating, validate } = useTokenValidation(token, isPending);
const [isTokenValidating, setIsTokenValidating] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [newPasswordRepeat, setNewPasswordRepeat] = useState('');
@ -38,12 +51,9 @@ export function Component() {
}
}
useEffect(() => {
if (!isTokenValidating && !isPending) {
void validateToken({ token: token });
setIsTokenValidating(true);
void validate();
}
}, [token, validateToken, isTokenValidating, isPending]);
if (isPending) {
return <Loader />;

View File

@ -32,7 +32,7 @@ export function Component() {
);
} else {
return (
<form className='cc-fade-in cc-column w-96 mx-auto px-6 mt-3' onSubmit={handleSubmit} onChange={clearServerError}>
<form className='cc-column w-96 mx-auto px-6 mt-3' onSubmit={handleSubmit} onChange={clearServerError}>
<TextInput
id='email'
autoComplete='email'

View File

@ -51,7 +51,7 @@ export function BadgeHelp({ topic, padding = 'p-1', className, contentClass, sty
{...restProps}
>
<Suspense fallback={<Loader />}>
<div className='absolute right-1 text-sm top-2 bg-input' onClick={event => event.stopPropagation()}>
<div className='absolute right-1 text-sm top-2 bg-' onClick={event => event.stopPropagation()}>
<TextURL text='Справка...' href={`/manuals?topic=${topic}`} />
</div>
<TopicPage topic={topic} />

View File

@ -3,12 +3,15 @@ import {
IconAnimation,
IconAnimationOff,
IconChild,
IconConceptBlock,
IconConnect,
IconConsolidation,
IconCoordinates,
IconDestroy,
IconEdit2,
IconExecute,
IconFitImage,
IconFixLayout,
IconGrid,
IconLineStraight,
IconLineWave,
@ -16,7 +19,8 @@ import {
IconNewRSForm,
IconReset,
IconRSForm,
IconSave
IconSave,
IconSettings
} from '@/components/icons';
import { LinkTopic } from '../../components/link-topic';
@ -29,9 +33,19 @@ export function HelpOssGraph() {
<div className='flex flex-col sm:flex-row'>
<div className='sm:w-56'>
<h1>Настройка графа</h1>
<li>
<IconReset className='inline-icon' /> Сбросить изменения
</li>
<li>
<IconFitImage className='inline-icon' /> Вписать в экран
</li>
<li>
<IconFixLayout className='inline-icon' /> Исправить расположения
</li>
<li>
<IconSettings className='inline-icon' /> Диалог настроек
</li>
<li>
<IconGrid className='inline-icon' /> Отображение сетки
</li>
@ -43,6 +57,9 @@ export function HelpOssGraph() {
<IconAnimation className='inline-icon' />
<IconAnimationOff className='inline-icon' /> Анимация
</li>
<li>
<IconCoordinates className='inline-icon' /> Отображение координат
</li>
<li>черта сверху - Загрузка</li>
<li>
черта слева - КС <LinkTopic text='внешняя' topic={HelpTopic.CC_OSS} />
@ -63,11 +80,14 @@ export function HelpOssGraph() {
<kbd>Двойной клик</kbd> переход к связанной <LinkTopic text='КС' topic={HelpTopic.CC_SYSTEM} />
</li>
<li>
<IconEdit2 className='inline-icon' /> Редактирование операции
<IconConceptBlock className='inline-icon icon-green' /> Новый блок
</li>
<li>
<IconNewItem className='inline-icon icon-green' /> Новая операция
</li>
<li>
<IconEdit2 className='inline-icon' /> Редактирование узла
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> <kbd>Delete</kbd> удалить выбранные
</li>
@ -80,10 +100,13 @@ export function HelpOssGraph() {
<div className='sm:w-56'>
<h1>Общие</h1>
<li>
<IconReset className='inline-icon' /> Сбросить изменения
<IconSave className='inline-icon' /> Сохранить положения
</li>
<li>
<IconSave className='inline-icon' /> Сохранить положения
<kbd>Space</kbd> перемещение экрана
</li>
<li>
<kbd>Shift</kbd> перемещение выделенных элементов в границах родителя
</li>
</div>

View File

@ -71,12 +71,12 @@ export function HelpRSEditor() {
<span className='bg-selected'>текущая конституента</span>
</li>
<li>
<span className='bg-(--acc-bg-green50)'>
<span className='bg-accent-green50'>
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
<li>
<span className='bg-(--acc-bg-orange50)'>
<span className='bg-accent-orange50'>
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>

View File

@ -10,7 +10,9 @@ import {
IconEditor,
IconMenu,
IconOwner,
IconQR,
IconReader,
IconRobot,
IconShare,
IconUpload
} from '@/components/icons';
@ -51,6 +53,12 @@ export function HelpRSMenu() {
<li>
<IconShare className='inline-icon' /> Поделиться скопировать ссылку на схему
</li>
<li>
<IconQR className='inline-icon' /> Отобразить QR-код схемы
</li>
<li>
<IconRobot className='inline-icon' /> Генерировать запрос для LLM
</li>
<li>
<IconClone className='inline-icon icon-green' /> Клонировать создать копию схемы
</li>

View File

@ -19,13 +19,13 @@ export function HelpTypeGraph() {
<h2>Виды узлов</h2>
<li>
<span className='bg-prim-200'>ступень-основание</span>
<span className='bg-secondary'>ступень-основание</span>
</li>
<li>
<span className='bg-(--acc-bg-teal)'>ступень-булеан</span>
<span className='bg-accent-teal'>ступень-булеан</span>
</li>
<li>
<span className='bg-(--acc-bg-orange)'>ступень декартова произведения</span>
<span className='bg-accent-orange'>ступень декартова произведения</span>
</li>
</div>
);

View File

@ -57,7 +57,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
getLabel={labelHelpTopic}
getDescription={describeHelpTopic}
className={clsx(
'cc-topic-dropdown border-r border-t rounded-none cc-scroll-y bg-prim-200',
'cc-topic-dropdown border-r border-t rounded-none cc-scroll-y bg-secondary',
menu.isOpen && 'open'
)}
style={{ maxHeight: treeHeight }}

View File

@ -29,7 +29,7 @@ export function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps)
'cc-scroll-y',
'self-start',
'border-x border-t rounded-none',
'text-xs sm:text-sm bg-prim-200',
'text-xs sm:text-sm bg-secondary',
'select-none'
)}
style={{ maxHeight: topicsHeight }}

View File

@ -13,11 +13,7 @@ interface ViewTopicProps {
export function ViewTopic({ topic }: ViewTopicProps) {
const mainHeight = useMainHeight();
return (
<div
key={topic}
className='cc-fade-in py-2 px-6 mx-auto sm:mx-0 lg:px-12 overflow-y-auto'
style={{ maxHeight: mainHeight }}
>
<div key={topic} className='py-2 px-6 mx-auto sm:mx-0 lg:px-12 overflow-y-auto' style={{ maxHeight: mainHeight }}>
<TopicPage topic={topic} />
</div>
);

View File

@ -16,7 +16,7 @@ export function Component() {
}, [hideFooter]);
return (
<div className='cc-fade-in flex justify-center overflow-hidden' style={{ maxHeight: panelHeight }}>
<div className='flex justify-center overflow-hidden' style={{ maxHeight: panelHeight }}>
<TransformWrapper>
<TransformComponent>
<img alt='Схема базы данных' src={resources.db_schema} className='w-fit h-fit' />

View File

@ -15,12 +15,12 @@ import {
type AccessPolicy,
type ICloneLibraryItemDTO,
type ICreateLibraryItemDTO,
type ICreateVersionDTO,
type ILibraryItem,
type IRenameLocationDTO,
type IUpdateLibraryItemDTO,
type IVersionCreateDTO,
type IUpdateVersionDTO,
type IVersionExInfo,
type IVersionUpdateDTO,
schemaLibraryItem,
schemaLibraryItemArray,
schemaVersionExInfo
@ -136,8 +136,8 @@ export const libraryApi = {
}
}),
versionCreate: ({ itemID, data }: { itemID: number; data: IVersionCreateDTO }) =>
axiosPost<IVersionCreateDTO, IVersionCreatedResponse>({
createVersion: ({ itemID, data }: { itemID: number; data: ICreateVersionDTO }) =>
axiosPost<ICreateVersionDTO, IVersionCreatedResponse>({
schema: schemaVersionCreatedResponse,
endpoint: `/api/library/${itemID}/create-version`,
request: {
@ -145,7 +145,7 @@ export const libraryApi = {
successMessage: infoMsg.newVersion(data.version)
}
}),
versionRestore: ({ versionID }: { versionID: number }) =>
restoreVersion: ({ versionID }: { versionID: number }) =>
axiosPatch<undefined, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/versions/${versionID}/restore`,
@ -153,8 +153,8 @@ export const libraryApi = {
successMessage: infoMsg.versionRestored
}
}),
versionUpdate: (data: { itemID: number; version: IVersionUpdateDTO }) =>
axiosPatch<IVersionUpdateDTO, IVersionExInfo>({
updateVersion: (data: { itemID: number; version: IUpdateVersionDTO }) =>
axiosPatch<IUpdateVersionDTO, IVersionExInfo>({
schema: schemaVersionExInfo,
endpoint: `/api/versions/${data.version.id}`,
request: {
@ -162,11 +162,11 @@ export const libraryApi = {
successMessage: infoMsg.changesSaved
}
}),
versionDelete: (data: { itemID: number; versionID: number }) =>
deleteVersion: (data: { itemID: number; versionID: number }) =>
axiosDelete({
endpoint: `/api/versions/${data.versionID}`,
request: {
successMessage: infoMsg.versionDestroyed
}
})
};
} as const;

View File

@ -49,10 +49,10 @@ export type ICreateLibraryItemDTO = z.infer<typeof schemaCreateLibraryItem>;
export type IUpdateLibraryItemDTO = z.infer<typeof schemaUpdateLibraryItem>;
/** Create version metadata in persistent storage. */
export type IVersionCreateDTO = z.infer<typeof schemaVersionCreate>;
export type ICreateVersionDTO = z.infer<typeof schemaCreateVersion>;
/** Represents version data, intended to update version metadata in persistent storage. */
export type IVersionUpdateDTO = z.infer<typeof schemaVersionUpdate>;
export type IUpdateVersionDTO = z.infer<typeof schemaUpdateVersion>;
// ======= SCHEMAS =========
export const schemaLibraryItemType = z.enum(Object.values(LibraryItemType) as [LibraryItemType, ...LibraryItemType[]]);
@ -140,13 +140,13 @@ export const schemaVersionExInfo = schemaVersionInfo.extend({
item: z.number()
});
export const schemaVersionUpdate = z.strictObject({
export const schemaUpdateVersion = z.strictObject({
id: z.number(),
version: z.string().nonempty(errorMsg.requiredField),
description: z.string()
});
export const schemaVersionCreate = z.strictObject({
export const schemaCreateVersion = z.strictObject({
version: z.string(),
description: z.string(),
items: z.array(z.number())

View File

@ -3,15 +3,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { libraryApi } from './api';
import { type IVersionCreateDTO } from './types';
import { type ICreateVersionDTO } from './types';
import { useUpdateTimestamp } from './use-update-timestamp';
export const useVersionCreate = () => {
export const useCreateVersion = () => {
const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'create-version'],
mutationFn: libraryApi.versionCreate,
mutationFn: libraryApi.createVersion,
onSuccess: data => {
client.setQueryData(KEYS.composite.rsItem({ itemID: data.schema.id }), data.schema);
updateTimestamp(data.schema.id);
@ -19,7 +19,7 @@ export const useVersionCreate = () => {
onError: () => client.invalidateQueries()
});
return {
versionCreate: (data: { itemID: number; data: IVersionCreateDTO }) =>
createVersion: (data: { itemID: number; data: ICreateVersionDTO }) =>
mutation.mutateAsync(data).then(response => response.version)
};
};

View File

@ -6,11 +6,11 @@ import { KEYS } from '@/backend/configuration';
import { libraryApi } from './api';
export const useVersionDelete = () => {
export const useDeleteVersion = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-version'],
mutationFn: libraryApi.versionDelete,
mutationFn: libraryApi.deleteVersion,
onSuccess: (_, variables) => {
client.setQueryData(KEYS.composite.rsItem({ itemID: variables.itemID }), (prev: IRSFormDTO | undefined) =>
!prev
@ -24,6 +24,6 @@ export const useVersionDelete = () => {
onError: () => client.invalidateQueries()
});
return {
versionDelete: (data: { itemID: number; versionID: number }) => mutation.mutateAsync(data)
deleteVersion: (data: { itemID: number; versionID: number }) => mutation.mutateAsync(data)
};
};

View File

@ -7,6 +7,11 @@ import { usePreferencesStore } from '@/stores/preferences';
import { libraryApi } from './api';
export function useLibraryListKey() {
const adminMode = usePreferencesStore(state => state.adminMode);
return libraryApi.getLibraryQueryOptions({ isAdmin: adminMode }).queryKey;
}
export function useLibrarySuspense() {
const adminMode = usePreferencesStore(state => state.adminMode);
const { user } = useAuthSuspense();

View File

@ -4,11 +4,11 @@ import { KEYS } from '@/backend/configuration';
import { libraryApi } from './api';
export const useVersionRestore = () => {
export const useRestoreVersion = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'restore-version'],
mutationFn: libraryApi.versionRestore,
mutationFn: libraryApi.restoreVersion,
onSuccess: data => {
client.setQueryData(KEYS.composite.rsItem({ itemID: data.id }), data);
return client.invalidateQueries({ queryKey: [libraryApi.baseKey] });
@ -16,6 +16,6 @@ export const useVersionRestore = () => {
onError: () => client.invalidateQueries()
});
return {
versionRestore: (data: { versionID: number }) => mutation.mutateAsync(data)
restoreVersion: (data: { versionID: number }) => mutation.mutateAsync(data)
};
};

View File

@ -4,12 +4,15 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api';
import { type AccessPolicy, type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export const useSetAccessPolicy = () => {
const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-location'],
mutationFn: libraryApi.setAccessPolicy,
@ -36,7 +39,7 @@ export const useSetAccessPolicy = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, access_policy: variables.policy }
);
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === variables.itemID ? { ...item, access_policy: variables.policy } : item))
);
},

View File

@ -4,12 +4,15 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api';
import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export const useSetLocation = () => {
const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-location'],
mutationFn: libraryApi.setLocation,
@ -36,7 +39,7 @@ export const useSetLocation = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, location: variables.location }
);
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === variables.itemID ? { ...item, location: variables.location } : item))
);
},

View File

@ -4,12 +4,15 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api';
import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export const useSetOwner = () => {
const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-owner'],
mutationFn: libraryApi.setOwner,
@ -36,7 +39,7 @@ export const useSetOwner = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, owner: variables.owner }
);
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === variables.itemID ? { ...item, owner: variables.owner } : item))
);
},

View File

@ -4,12 +4,15 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api';
import { type ILibraryItem, type IUpdateLibraryItemDTO, LibraryItemType } from './types';
import { useLibraryListKey } from './use-library';
export const useUpdateItem = () => {
const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'update-item'],
mutationFn: libraryApi.updateItem,
@ -18,7 +21,7 @@ export const useUpdateItem = () => {
data.item_type === LibraryItemType.RSFORM
? KEYS.composite.rsItem({ itemID: data.id })
: KEYS.composite.ossItem({ itemID: data.id });
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === data.id ? data : item))
);
client.setQueryData(itemKey, (prev: IRSFormDTO | IOperationSchemaDTO | undefined) =>

View File

@ -1,15 +1,18 @@
import { useQueryClient } from '@tanstack/react-query';
import { libraryApi } from './api';
import { type RO } from '@/utils/meta';
import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export function useUpdateTimestamp() {
const client = useQueryClient();
const libraryKey = useLibraryListKey();
return {
updateTimestamp: (target: number) =>
client.setQueryData(
libraryApi.libraryListKey, //
(prev: ILibraryItem[] | undefined) =>
libraryKey, //
(prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === target ? { ...item, time_update: Date() } : item))
)
};

View File

@ -5,13 +5,13 @@ import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration';
import { libraryApi } from './api';
import { type IVersionUpdateDTO } from './types';
import { type IUpdateVersionDTO } from './types';
export const useVersionUpdate = () => {
export const useUpdateVersion = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'update-version'],
mutationFn: libraryApi.versionUpdate,
mutationFn: libraryApi.updateVersion,
onSuccess: (data, variables) => {
client.setQueryData(KEYS.composite.rsItem({ itemID: variables.itemID }), (prev: IRSFormDTO | undefined) =>
!prev
@ -41,6 +41,6 @@ export const useVersionUpdate = () => {
onError: () => client.invalidateQueries()
});
return {
versionUpdate: (data: { itemID: number; version: IVersionUpdateDTO }) => mutation.mutateAsync(data)
updateVersion: (data: { itemID: number; version: IUpdateVersionDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -8,7 +8,6 @@ import { IconClose, IconFolderTree } from '@/components/icons';
import { SearchBar } from '@/components/input';
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { APP_COLORS } from '@/styling/colors';
import { prefixes } from '@/utils/constants';
import { type ILibraryItem, type LibraryItemType } from '../backend/types';
@ -91,7 +90,7 @@ export function PickSchema({
const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [
{
when: (item: ILibraryItem) => item.id === value,
style: { backgroundColor: APP_COLORS.bgSelected }
className: 'bg-selected'
}
];

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',
'cc-hover cc-animate-color duration-fade',
'cursor-pointer',
'leading-3 sm:leading-4',
activeNode === item && 'cc-selected'

View File

@ -8,8 +8,8 @@ import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels';
import { type IVersionCreateDTO, type IVersionInfo, schemaVersionCreate } from '../backend/types';
import { useVersionCreate } from '../backend/use-version-create';
import { type ICreateVersionDTO, type IVersionInfo, schemaCreateVersion } from '../backend/types';
import { useCreateVersion } from '../backend/use-create-version';
import { nextVersion } from '../models/library-api';
export interface DlgCreateVersionProps {
@ -24,10 +24,10 @@ export function DlgCreateVersion() {
const { itemID, versions, selected, totalCount, onCreate } = useDialogsStore(
state => state.props as DlgCreateVersionProps
);
const { versionCreate } = useVersionCreate();
const { createVersion: versionCreate } = useCreateVersion();
const { register, handleSubmit, control } = useForm<IVersionCreateDTO>({
resolver: zodResolver(schemaVersionCreate),
const { register, handleSubmit, control } = useForm<ICreateVersionDTO>({
resolver: zodResolver(schemaCreateVersion),
defaultValues: {
version: versions.length > 0 ? nextVersion(versions[versions.length - 1].version) : '1.0.0',
description: '',
@ -37,7 +37,7 @@ export function DlgCreateVersion() {
const version = useWatch({ control, name: 'version' });
const canSubmit = !versions.find(ver => ver.version === version);
function onSubmit(data: IVersionCreateDTO) {
function onSubmit(data: ICreateVersionDTO) {
return versionCreate({ itemID, data }).then(onCreate);
}

View File

@ -14,10 +14,10 @@ import { ModalView } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels';
import { type IVersionUpdateDTO, schemaVersionUpdate } from '../../backend/types';
import { type IUpdateVersionDTO, schemaUpdateVersion } from '../../backend/types';
import { useDeleteVersion } from '../../backend/use-delete-version';
import { useMutatingLibrary } from '../../backend/use-mutating-library';
import { useVersionDelete } from '../../backend/use-version-delete';
import { useVersionUpdate } from '../../backend/use-version-update';
import { useUpdateVersion } from '../../backend/use-update-version';
import { TableVersions } from './table-versions';
@ -31,8 +31,8 @@ export function DlgEditVersions() {
const hideDialog = useDialogsStore(state => state.hideDialog);
const { schema } = useRSFormSuspense({ itemID });
const isProcessing = useMutatingLibrary();
const { versionDelete } = useVersionDelete();
const { versionUpdate } = useVersionUpdate();
const { deleteVersion: versionDelete } = useDeleteVersion();
const { updateVersion: versionUpdate } = useUpdateVersion();
const {
register,
@ -40,8 +40,8 @@ export function DlgEditVersions() {
control,
reset,
formState: { isDirty, errors: formErrors }
} = useForm<IVersionUpdateDTO>({
resolver: zodResolver(schemaVersionUpdate),
} = useForm<IUpdateVersionDTO>({
resolver: zodResolver(schemaUpdateVersion),
defaultValues: {
id: schema.versions[schema.versions.length - 1].id,
version: schema.versions[schema.versions.length - 1].version,
@ -77,7 +77,7 @@ export function DlgEditVersions() {
});
}
function onUpdate(data: IVersionUpdateDTO) {
function onUpdate(data: IUpdateVersionDTO) {
if (!isDirty || isProcessing || !isValid) {
return;
}

View File

@ -5,7 +5,6 @@ import { useIntl } from 'react-intl';
import { MiniButton } from '@/components/control';
import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/data-table';
import { IconRemove } from '@/components/icons';
import { APP_COLORS } from '@/styling/colors';
import { type IVersionInfo } from '../../backend/types';
@ -77,9 +76,7 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
const conditionalRowStyles: IConditionalStyle<IVersionInfo>[] = [
{
when: (version: IVersionInfo) => version.id === selected,
style: {
backgroundColor: APP_COLORS.bgSelected
}
className: 'bg-selected'
}
];

View File

@ -99,7 +99,7 @@ export function FormCreateItem() {
return (
<form
className='cc-fade-in cc-column w-120 mx-auto px-6 py-3'
className='cc-column w-120 mx-auto px-6 py-3'
onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
>

View File

@ -54,7 +54,7 @@ export function LibraryPage() {
return (
<>
<ToolbarSearch className='top-0 h-9' total={libraryItems.length} filtered={filtered.length} />
<div className='relative cc-fade-in flex'>
<div className='relative flex'>
<MiniButton
title='Выгрузить в формате CSV'
className='absolute z-tooltip -top-8 right-6 hidden sm:block'

View File

@ -9,7 +9,7 @@ import { DataTable, type IConditionalStyle, type VisibilityState } from '@/compo
import { useWindowSize } from '@/hooks/use-window-size';
import { useFitHeight } from '@/stores/app-layout';
import { usePreferencesStore } from '@/stores/preferences';
import { APP_COLORS } from '@/styling/colors';
import { type RO } from '@/utils/meta';
import { type ILibraryItem, LibraryItemType } from '../../backend/types';
import { useLibrarySearchStore } from '../../stores/library-search';
@ -17,7 +17,7 @@ import { useLibrarySearchStore } from '../../stores/library-search';
import { useLibraryColumns } from './use-library-columns';
interface TableLibraryItemsProps {
items: ILibraryItem[];
items: RO<ILibraryItem[]>;
}
export function TableLibraryItems({ items }: TableLibraryItemsProps) {
@ -35,9 +35,7 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [
{
when: (item: ILibraryItem) => item.item_type === LibraryItemType.OSS,
style: {
color: APP_COLORS.fgGreen
}
className: 'text-accent-green-foreground'
}
];
const tableHeight = useFitHeight('2.25rem');
@ -58,7 +56,7 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
<DataTable
id='library_data'
columns={columns}
data={items}
data={items as ILibraryItem[]}
headPosition='0'
className={clsx('cc-scroll-y h-fit text-xs sm:text-sm border-b', folderMode && 'border-l')}
style={{ maxHeight: tableHeight }}

View File

@ -128,7 +128,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
/>
</div>
<div className='flex h-full grow pr-4'>
<div className='flex h-full grow pr-4 sm:pr-12'>
<SearchBar
id='library_search'
placeholder='Поиск'

View File

@ -6,12 +6,13 @@ import { MiniButton } from '@/components/control';
import { createColumnHelper } from '@/components/data-table';
import { IconFolderTree } from '@/components/icons';
import { useWindowSize } from '@/hooks/use-window-size';
import { type RO } from '@/utils/meta';
import { type ILibraryItem } from '../../backend/types';
import { BadgeLocation } from '../../components/badge-location';
import { useLibrarySearchStore } from '../../stores/library-search';
const columnHelper = createColumnHelper<ILibraryItem>();
const columnHelper = createColumnHelper<RO<ILibraryItem>>();
export function useLibraryColumns() {
const { isSmall } = useWindowSize();

View File

@ -5,17 +5,23 @@ import { DELAYS, KEYS } from '@/backend/configuration';
import { infoMsg } from '@/utils/labels';
import {
type IBlockCreatedResponse,
type IConstituentaReference,
type ICstRelocateDTO,
type ICreateBlockDTO,
type ICreateOperationDTO,
type IDeleteBlockDTO,
type IDeleteOperationDTO,
type IInputCreatedResponse,
type IInputUpdateDTO,
type IMoveItemsDTO,
type IOperationCreatedResponse,
type IOperationCreateDTO,
type IOperationDeleteDTO,
type IOperationSchemaDTO,
type IOperationUpdateDTO,
type IOssLayout,
type IRelocateConstituentsDTO,
type ITargetOperation,
type IUpdateBlockDTO,
type IUpdateInputDTO,
type IUpdateOperationDTO,
schemaBlockCreatedResponse,
schemaConstituentaReference,
schemaInputCreatedResponse,
schemaOperationCreatedResponse,
@ -49,8 +55,36 @@ export const ossApi = {
}
}),
operationCreate: ({ itemID, data }: { itemID: number; data: IOperationCreateDTO }) =>
axiosPost<IOperationCreateDTO, IOperationCreatedResponse>({
createBlock: ({ itemID, data }: { itemID: number; data: ICreateBlockDTO }) =>
axiosPost<ICreateBlockDTO, IBlockCreatedResponse>({
schema: schemaBlockCreatedResponse,
endpoint: `/api/oss/${itemID}/create-block`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
updateBlock: ({ itemID, data }: { itemID: number; data: IUpdateBlockDTO }) =>
axiosPatch<IUpdateBlockDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/update-block`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
deleteBlock: ({ itemID, data }: { itemID: number; data: IDeleteBlockDTO }) =>
axiosPatch<IDeleteBlockDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/delete-block`,
request: {
data: data,
successMessage: infoMsg.blockDestroyed
}
}),
createOperation: ({ itemID, data }: { itemID: number; data: ICreateOperationDTO }) =>
axiosPost<ICreateOperationDTO, IOperationCreatedResponse>({
schema: schemaOperationCreatedResponse,
endpoint: `/api/oss/${itemID}/create-operation`,
request: {
@ -58,8 +92,17 @@ export const ossApi = {
successMessage: response => infoMsg.newOperation(response.new_operation.alias)
}
}),
operationDelete: ({ itemID, data }: { itemID: number; data: IOperationDeleteDTO }) =>
axiosPatch<IOperationDeleteDTO, IOperationSchemaDTO>({
updateOperation: ({ itemID, data }: { itemID: number; data: IUpdateOperationDTO }) =>
axiosPatch<IUpdateOperationDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/update-operation`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
deleteOperation: ({ itemID, data }: { itemID: number; data: IDeleteOperationDTO }) =>
axiosPatch<IDeleteOperationDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/delete-operation`,
request: {
@ -67,7 +110,8 @@ export const ossApi = {
successMessage: infoMsg.operationDestroyed
}
}),
inputCreate: ({ itemID, data }: { itemID: number; data: ITargetOperation }) =>
createInput: ({ itemID, data }: { itemID: number; data: ITargetOperation }) =>
axiosPatch<ITargetOperation, IInputCreatedResponse>({
schema: schemaInputCreatedResponse,
endpoint: `/api/oss/${itemID}/create-input`,
@ -76,8 +120,8 @@ export const ossApi = {
successMessage: infoMsg.newLibraryItem
}
}),
inputUpdate: ({ itemID, data }: { itemID: number; data: IInputUpdateDTO }) =>
axiosPatch<IInputUpdateDTO, IOperationSchemaDTO>({
updateInput: ({ itemID, data }: { itemID: number; data: IUpdateInputDTO }) =>
axiosPatch<IUpdateInputDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/set-input`,
request: {
@ -85,16 +129,7 @@ export const ossApi = {
successMessage: infoMsg.changesSaved
}
}),
operationUpdate: ({ itemID, data }: { itemID: number; data: IOperationUpdateDTO }) =>
axiosPatch<IOperationUpdateDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/update-operation`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
operationExecute: ({ itemID, data }: { itemID: number; data: ITargetOperation }) =>
executeOperation: ({ itemID, data }: { itemID: number; data: ITargetOperation }) =>
axiosPost<ITargetOperation, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/execute-operation`,
@ -104,8 +139,18 @@ export const ossApi = {
}
}),
relocateConstituents: (data: ICstRelocateDTO) =>
axiosPost<ICstRelocateDTO, IOperationSchemaDTO>({
moveItems: ({ itemID, data }: { itemID: number; data: IMoveItemsDTO }) =>
axiosPatch<IMoveItemsDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/${itemID}/move-items`,
request: {
data: data,
successMessage: infoMsg.moveSuccess
}
}),
relocateConstituents: (data: IRelocateConstituentsDTO) =>
axiosPost<IRelocateConstituentsDTO, IOperationSchemaDTO>({
schema: schemaOperationSchema,
endpoint: `/api/oss/relocate-constituents`,
request: {
@ -119,4 +164,4 @@ export const ossApi = {
endpoint: '/api/oss/get-predecessor',
request: { data: { target: cstID } }
})
};
} as const;

View File

@ -5,24 +5,25 @@
import { type ILibraryItem } from '@/features/library';
import { Graph } from '@/models/graph';
import { type RO } from '@/utils/meta';
import { type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss';
import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node';
import { type IOperationSchemaDTO, OperationType } from './types';
/**
* Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}.
*
*/
/** Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}. */
export class OssLoader {
private oss: IOperationSchema;
private graph: Graph = new Graph();
private hierarchy: Graph = new Graph();
private operationByID = new Map<number, IOperation>();
private blockByID = new Map<number, IBlock>();
private schemaIDs: number[] = [];
private items: ILibraryItem[];
private items: RO<ILibraryItem[]>;
constructor(input: IOperationSchemaDTO, items: ILibraryItem[]) {
this.oss = input as unknown as IOperationSchema;
constructor(input: RO<IOperationSchemaDTO>, items: RO<ILibraryItem[]>) {
this.oss = structuredClone(input) as IOperationSchema;
this.items = items;
}
@ -32,9 +33,12 @@ export class OssLoader {
this.createGraph();
this.extractSchemas();
this.inferOperationAttributes();
this.inferBlockAttributes();
result.operationByID = this.operationByID;
result.blockByID = this.blockByID;
result.graph = this.graph;
result.hierarchy = this.hierarchy;
result.schemas = this.schemaIDs;
result.stats = this.calculateStats();
return result;
@ -44,6 +48,17 @@ export class OssLoader {
this.oss.operations.forEach(operation => {
this.operationByID.set(operation.id, operation);
this.graph.addNode(operation.id);
this.hierarchy.addNode(operation.id);
if (operation.parent) {
this.hierarchy.addEdge(-operation.parent, operation.id);
}
});
this.oss.blocks.forEach(block => {
this.blockByID.set(block.id, block);
this.hierarchy.addNode(-block.id);
if (block.parent) {
this.hierarchy.addEdge(-block.parent, -block.id);
}
});
}
@ -71,6 +86,16 @@ export class OssLoader {
});
}
private inferBlockAttributes() {
this.oss.blocks.forEach(block => {
const geometry = this.oss.layout.blocks.find(item => item.id === block.id);
block.x = geometry?.x ?? 0;
block.y = geometry?.y ?? 0;
block.width = geometry?.width ?? BLOCK_NODE_MIN_WIDTH;
block.height = geometry?.height ?? BLOCK_NODE_MIN_HEIGHT;
});
}
private inferConsolidation(operationID: number): boolean {
const inputs = this.graph.expandInputs([operationID]);
if (inputs.length === 0) {
@ -85,13 +110,14 @@ export class OssLoader {
}
private calculateStats(): IOperationSchemaStats {
const items = this.oss.operations;
const operations = this.oss.operations;
return {
count_operations: items.length,
count_inputs: items.filter(item => item.operation_type === OperationType.INPUT).length,
count_synthesis: items.filter(item => item.operation_type === OperationType.SYNTHESIS).length,
count_all: this.oss.operations.length + this.oss.blocks.length,
count_inputs: operations.filter(item => item.operation_type === OperationType.INPUT).length,
count_synthesis: operations.filter(item => item.operation_type === OperationType.SYNTHESIS).length,
count_schemas: this.schemaIDs.length,
count_owned: items.filter(item => !!item.result && item.is_owned).length
count_owned: operations.filter(item => !!item.result && item.is_owned).length,
count_block: this.oss.blocks.length
};
}
}

View File

@ -1,7 +1,7 @@
import { z } from 'zod';
import { schemaLibraryItem } from '@/features/library/backend/types';
import { schemaCstSubstitute } from '@/features/rsform/backend/types';
import { schemaSubstituteConstituents } from '@/features/rsform/backend/types';
import { errorMsg } from '@/utils/labels';
@ -18,7 +18,7 @@ export type ICstSubstituteInfo = z.infer<typeof schemaCstSubstituteInfo>;
/** Represents {@link IOperation} data from server. */
export type IOperationDTO = z.infer<typeof schemaOperation>;
/** Represents {@link IOperation} data from server. */
/** Represents {@link IBlock} data from server. */
export type IBlockDTO = z.infer<typeof schemaBlock>;
/** Represents backend data for {@link IOperationSchema}. */
@ -27,33 +27,47 @@ export type IOperationSchemaDTO = z.infer<typeof schemaOperationSchema>;
/** Represents {@link IOperationSchema} layout. */
export type IOssLayout = z.infer<typeof schemaOssLayout>;
/** Represents {@link IOperation} data, used in creation process. */
export type IOperationCreateDTO = z.infer<typeof schemaOperationCreate>;
/** Represents {@link IBlock} data, used in Create action. */
export type ICreateBlockDTO = z.infer<typeof schemaCreateBlock>;
/** Represents data response when creating {@link IBlock}. */
export type IBlockCreatedResponse = z.infer<typeof schemaBlockCreatedResponse>;
/** Represents {@link IBlock} data, used in Update action. */
export type IUpdateBlockDTO = z.infer<typeof schemaUpdateBlock>;
/** Represents {@link IBlock} data, used in Delete action. */
export type IDeleteBlockDTO = z.infer<typeof schemaDeleteBlock>;
/** Represents data, used to move {@link IOssItem} to another parent. */
export type IMoveItemsDTO = z.infer<typeof schemaMoveItems>;
/** Represents {@link IOperation} data, used in Create action. */
export type ICreateOperationDTO = z.infer<typeof schemaCreateOperation>;
/** Represents data response when creating {@link IOperation}. */
export type IOperationCreatedResponse = z.infer<typeof schemaOperationCreatedResponse>;
/**
* Represents target {@link IOperation}.
*/
/** Represents {@link IOperation} data, used in Update action. */
export type IUpdateOperationDTO = z.infer<typeof schemaUpdateOperation>;
/** Represents {@link IOperation} data, used in Delete action. */
export type IDeleteOperationDTO = z.infer<typeof schemaDeleteOperation>;
/** Represents target {@link IOperation}. */
export interface ITargetOperation {
layout: IOssLayout;
target: number;
}
/** Represents {@link IOperation} data, used in destruction process. */
export type IOperationDeleteDTO = z.infer<typeof schemaOperationDelete>;
/** Represents data response when creating {@link IRSForm} for Input {@link IOperation}. */
export type IInputCreatedResponse = z.infer<typeof schemaInputCreatedResponse>;
/** Represents {@link IOperation} data, used in setInput process. */
export type IInputUpdateDTO = z.infer<typeof schemaInputUpdate>;
/** Represents {@link IOperation} data, used in update process. */
export type IOperationUpdateDTO = z.infer<typeof schemaOperationUpdate>;
/** Represents {@link IOperation} data, used in setInput action. */
export type IUpdateInputDTO = z.infer<typeof schemaUpdateInput>;
/** Represents data, used relocating {@link IConstituenta}s between {@link ILibraryItem}s. */
export type ICstRelocateDTO = z.infer<typeof schemaCstRelocate>;
export type IRelocateConstituentsDTO = z.infer<typeof schemaRelocateConstituents>;
/** Represents {@link IConstituenta} reference. */
export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>;
@ -80,7 +94,7 @@ export const schemaBlock = z.strictObject({
parent: z.number().nullable()
});
export const schemaCstSubstituteInfo = schemaCstSubstitute.extend({
export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({
operation: z.number(),
original_alias: z.string(),
original_term: z.string(),
@ -121,7 +135,42 @@ export const schemaOperationSchema = schemaLibraryItem.extend({
substitutions: z.array(schemaCstSubstituteInfo)
});
export const schemaOperationCreate = z.strictObject({
export const schemaCreateBlock = z.strictObject({
layout: schemaOssLayout,
item_data: z.strictObject({
title: z.string(),
description: z.string(),
parent: z.number().nullable()
}),
position_x: z.number(),
position_y: z.number(),
width: z.number(),
height: z.number(),
children_operations: z.array(z.number()),
children_blocks: z.array(z.number())
});
export const schemaBlockCreatedResponse = z.strictObject({
new_block: schemaBlock,
oss: schemaOperationSchema
});
export const schemaUpdateBlock = z.strictObject({
target: z.number(),
layout: schemaOssLayout,
item_data: z.strictObject({
title: z.string(),
description: z.string(),
parent: z.number().nullable()
})
});
export const schemaDeleteBlock = z.strictObject({
target: z.number(),
layout: schemaOssLayout
});
export const schemaCreateOperation = z.strictObject({
layout: schemaOssLayout,
item_data: z.strictObject({
alias: z.string().nonempty(),
@ -142,14 +191,34 @@ export const schemaOperationCreatedResponse = z.strictObject({
oss: schemaOperationSchema
});
export const schemaOperationDelete = z.strictObject({
export const schemaUpdateOperation = z.strictObject({
target: z.number(),
layout: schemaOssLayout,
item_data: z.strictObject({
alias: z.string().nonempty(errorMsg.requiredField),
title: z.string(),
description: z.string(),
parent: z.number().nullable()
}),
arguments: z.array(z.number()),
substitutions: z.array(schemaSubstituteConstituents)
});
export const schemaDeleteOperation = z.strictObject({
target: z.number(),
layout: schemaOssLayout,
keep_constituents: z.boolean(),
delete_schema: z.boolean()
});
export const schemaInputUpdate = z.strictObject({
export const schemaMoveItems = z.strictObject({
layout: schemaOssLayout,
operations: z.array(z.number()),
blocks: z.array(z.number()),
destination: z.number().nullable()
});
export const schemaUpdateInput = z.strictObject({
target: z.number(),
layout: schemaOssLayout,
input: z.number().nullable()
@ -160,19 +229,7 @@ export const schemaInputCreatedResponse = z.strictObject({
oss: schemaOperationSchema
});
export const schemaOperationUpdate = z.strictObject({
target: z.number(),
layout: schemaOssLayout,
item_data: z.strictObject({
alias: z.string().nonempty(errorMsg.requiredField),
title: z.string(),
description: z.string()
}),
arguments: z.array(z.number()),
substitutions: z.array(schemaCstSubstitute)
});
export const schemaCstRelocate = z.strictObject({
export const schemaRelocateConstituents = z.strictObject({
destination: z.number().nullable(),
items: z.array(z.number()).refine(data => data.length > 0)
});

View File

@ -5,14 +5,14 @@ import { useUpdateTimestamp } from '@/features/library/backend/use-update-timest
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IOperationCreateDTO } from './types';
import { type ICreateBlockDTO } from './types';
export const useOperationCreate = () => {
export const useCreateBlock = () => {
const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-create'],
mutationFn: ossApi.operationCreate,
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'create-block'],
mutationFn: ossApi.createBlock,
onSuccess: response => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: response.oss.id }).queryKey, response.oss);
updateTimestamp(response.oss.id);
@ -20,6 +20,6 @@ export const useOperationCreate = () => {
onError: () => client.invalidateQueries()
});
return {
operationCreate: (data: { itemID: number; data: IOperationCreateDTO }) => mutation.mutateAsync(data)
createBlock: (data: { itemID: number; data: ICreateBlockDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -5,11 +5,11 @@ import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type ITargetOperation } from './types';
export const useInputCreate = () => {
export const useCreateInput = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'input-create'],
mutationFn: ossApi.inputCreate,
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'create-input'],
mutationFn: ossApi.createInput,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.oss.id }).queryKey, data.oss);
return Promise.allSettled([
@ -20,7 +20,7 @@ export const useInputCreate = () => {
onError: () => client.invalidateQueries()
});
return {
inputCreate: (data: { itemID: number; data: ITargetOperation }) =>
createInput: (data: { itemID: number; data: ITargetOperation }) =>
mutation.mutateAsync(data).then(response => response.new_schema)
};
};

View File

@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type ICreateOperationDTO } from './types';
export const useCreateOperation = () => {
const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'create-operation'],
mutationFn: ossApi.createOperation,
onSuccess: response => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: response.oss.id }).queryKey, response.oss);
updateTimestamp(response.oss.id);
},
onError: () => client.invalidateQueries()
});
return {
createOperation: (data: { itemID: number; data: ICreateOperationDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -3,13 +3,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IInputUpdateDTO } from './types';
import { type IDeleteBlockDTO } from './types';
export const useInputUpdate = () => {
export const useDeleteBlock = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'input-update'],
mutationFn: ossApi.inputUpdate,
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'delete-block'],
mutationFn: ossApi.deleteBlock,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([
@ -20,6 +20,6 @@ export const useInputUpdate = () => {
onError: () => client.invalidateQueries()
});
return {
inputUpdate: (data: { itemID: number; data: IInputUpdateDTO }) => mutation.mutateAsync(data)
deleteBlock: (data: { itemID: number; data: IDeleteBlockDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -3,13 +3,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IOperationDeleteDTO } from './types';
import { type IDeleteOperationDTO } from './types';
export const useOperationDelete = () => {
export const useDeleteOperation = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-delete'],
mutationFn: ossApi.operationDelete,
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'delete-operation'],
mutationFn: ossApi.deleteOperation,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([
@ -20,6 +20,6 @@ export const useOperationDelete = () => {
onError: () => client.invalidateQueries()
});
return {
operationDelete: (data: { itemID: number; data: IOperationDeleteDTO }) => mutation.mutateAsync(data)
deleteOperation: (data: { itemID: number; data: IDeleteOperationDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -5,11 +5,11 @@ import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type ITargetOperation } from './types';
export const useOperationExecute = () => {
export const useExecuteOperation = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-execute'],
mutationFn: ossApi.operationExecute,
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'execute-operation'],
mutationFn: ossApi.executeOperation,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([
@ -20,6 +20,6 @@ export const useOperationExecute = () => {
onError: () => client.invalidateQueries()
});
return {
operationExecute: (data: { itemID: number; data: ITargetOperation }) => mutation.mutateAsync(data)
executeOperation: (data: { itemID: number; data: ITargetOperation }) => mutation.mutateAsync(data)
};
};

View File

@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IMoveItemsDTO } from './types';
export const useMoveItems = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'move-items'],
mutationFn: ossApi.moveItems,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([
client.invalidateQueries({ queryKey: KEYS.composite.libraryList }),
client.invalidateQueries({ queryKey: [KEYS.rsform] })
]);
},
onError: () => client.invalidateQueries()
});
return {
moveItems: (data: { itemID: number; data: IMoveItemsDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type ICstRelocateDTO } from './types';
import { type IRelocateConstituentsDTO } from './types';
export const useRelocateConstituents = () => {
const client = useQueryClient();
@ -20,6 +20,6 @@ export const useRelocateConstituents = () => {
onError: () => client.invalidateQueries()
});
return {
relocateConstituents: (data: ICstRelocateDTO) => mutation.mutateAsync(data)
relocateConstituents: (data: IRelocateConstituentsDTO) => mutation.mutateAsync(data)
};
};

View File

@ -5,13 +5,13 @@ import { type ILibraryItem } from '@/features/library';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IOperationUpdateDTO } from './types';
import { type IUpdateBlockDTO } from './types';
export const useOperationUpdate = () => {
export const useUpdateBlock = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-update'],
mutationFn: ossApi.operationUpdate,
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'update-block'],
mutationFn: ossApi.updateBlock,
onSuccess: (data, variables) => {
client.setQueryData(KEYS.composite.ossItem({ itemID: data.id }), data);
const schemaID = data.operations.find(item => item.id === variables.data.target)?.result;
@ -32,6 +32,6 @@ export const useOperationUpdate = () => {
onError: () => client.invalidateQueries()
});
return {
operationUpdate: (data: { itemID: number; data: IOperationUpdateDTO }) => mutation.mutateAsync(data)
updateBlock: (data: { itemID: number; data: IUpdateBlockDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IUpdateInputDTO } from './types';
export const useUpdateInput = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'update-input'],
mutationFn: ossApi.updateInput,
onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([
client.invalidateQueries({ queryKey: KEYS.composite.libraryList }),
client.invalidateQueries({ queryKey: [KEYS.rsform] })
]);
},
onError: () => client.invalidateQueries()
});
return {
updateInput: (data: { itemID: number; data: IUpdateInputDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp';
import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { ossApi } from './api';
import { type IOperationSchemaDTO, type IOssLayout } from './types';
@ -17,7 +18,7 @@ export const useUpdateLayout = () => {
updateTimestamp(variables.itemID);
client.setQueryData(
ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey,
(prev: IOperationSchemaDTO | undefined) =>
(prev: RO<IOperationSchemaDTO> | undefined) =>
!prev
? prev
: {

View File

@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type ILibraryItem } from '@/features/library';
import { KEYS } from '@/backend/configuration';
import { ossApi } from './api';
import { type IUpdateOperationDTO } from './types';
export const useUpdateOperation = () => {
const client = useQueryClient();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'update-operation'],
mutationFn: ossApi.updateOperation,
onSuccess: (data, variables) => {
client.setQueryData(KEYS.composite.ossItem({ itemID: data.id }), data);
const schemaID = data.operations.find(item => item.id === variables.data.target)?.result;
if (!schemaID) {
return;
}
client.setQueryData(KEYS.composite.libraryList, (prev: ILibraryItem[] | undefined) =>
!prev
? undefined
: prev.map(item =>
item.id === schemaID ? { ...item, ...variables.data.item_data, time_update: Date() } : item
)
);
return client.invalidateQueries({
queryKey: KEYS.composite.rsItem({ itemID: schemaID })
});
},
onError: () => client.invalidateQueries()
});
return {
updateOperation: (data: { itemID: number; data: IUpdateOperationDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -0,0 +1,26 @@
'use client';
import { type IBlock } from '../models/oss';
interface InfoOperationProps {
block: IBlock;
}
export function InfoBlock({ block }: InfoOperationProps) {
return (
<>
{block.title ? (
<p>
<b>Название: </b>
{block.title}
</p>
) : null}
{block.description ? (
<p>
<b>Описание: </b>
{block.description}
</p>
) : null}
</>
);
}

View File

@ -1,21 +0,0 @@
import { Tooltip } from '@/components/container';
import { globalIDs } from '@/utils/constants';
import { useOperationTooltipStore } from '../stores/operation-tooltip';
import { InfoOperation } from './info-operation';
export function OperationTooltip() {
const hoverOperation = useOperationTooltipStore(state => state.activeOperation);
return (
<Tooltip
clickable
id={globalIDs.operation_tooltip}
layer='z-topmost'
className='max-w-140 dense max-h-120! overflow-y-auto!'
hidden={!hoverOperation}
>
{hoverOperation ? <InfoOperation operation={hoverOperation} /> : null}
</Tooltip>
);
}

View File

@ -0,0 +1,159 @@
'use client';
import { useState } from 'react';
import { MiniButton } from '@/components/control';
import { createColumnHelper, DataTable } from '@/components/data-table';
import { IconMoveDown, IconMoveUp, IconRemove } from '@/components/icons';
import { ComboBox } from '@/components/input/combo-box';
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { NoData } from '@/components/view';
import { type RO } from '@/utils/meta';
import { labelOssItem } from '../labels';
import { type IOperationSchema, type IOssItem } from '../models/oss';
import { getItemID, isOperation } from '../models/oss-api';
const SELECTION_CLEAR_TIMEOUT = 1000;
interface PickMultiOperationProps extends Styling {
value: number[];
onChange: (newValue: number[]) => void;
schema: IOperationSchema;
rows?: number;
exclude?: number[];
disallowBlocks?: boolean;
}
const columnHelper = createColumnHelper<RO<IOssItem>>();
export function PickContents({
rows,
schema,
exclude,
value,
disallowBlocks,
onChange,
className,
...restProps
}: PickMultiOperationProps) {
const selectedItems = value
.map(itemID => (itemID > 0 ? schema.operationByID.get(itemID) : schema.blockByID.get(-itemID)))
.filter(item => item !== undefined);
const [lastSelected, setLastSelected] = useState<RO<IOssItem> | null>(null);
const items = [
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))),
...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id))
];
function handleDelete(target: number) {
onChange(value.filter(item => item !== target));
}
function handleSelect(target: RO<IOssItem> | null) {
if (target) {
setLastSelected(target);
onChange([...value, getItemID(target)]);
setTimeout(() => setLastSelected(null), SELECTION_CLEAR_TIMEOUT);
}
}
function handleMoveUp(target: number) {
const index = value.indexOf(target);
if (index > 0) {
const newSelected = [...value];
newSelected[index] = newSelected[index - 1];
newSelected[index - 1] = target;
onChange(newSelected);
}
}
function handleMoveDown(target: number) {
const index = value.indexOf(target);
if (index < value.length - 1) {
const newSelected = [...value];
newSelected[index] = newSelected[index + 1];
newSelected[index + 1] = target;
onChange(newSelected);
}
}
const columns = [
columnHelper.accessor(item => isOperation(item), {
id: 'type',
header: 'Тип',
size: 150,
minSize: 150,
maxSize: 150,
cell: props => <div>{isOperation(props.row.original) ? 'Операция' : 'Блок'}</div>
}),
columnHelper.accessor('title', {
id: 'title',
header: 'Название',
size: 1200,
minSize: 300,
maxSize: 1200,
cell: props => <div className='text-ellipsis'>{props.getValue()}</div>
}),
columnHelper.display({
id: 'actions',
size: 0,
cell: props => (
<div className='flex w-fit'>
<MiniButton
title='Удалить'
noHover
className='px-0'
icon={<IconRemove size='1rem' className='icon-red' />}
onClick={() => handleDelete(getItemID(props.row.original))}
/>
<MiniButton
title='Переместить выше'
noHover
className='px-0'
icon={<IconMoveUp size='1rem' className='icon-primary' />}
onClick={() => handleMoveUp(getItemID(props.row.original))}
/>
<MiniButton
title='Переместить ниже'
noHover
className='px-0'
icon={<IconMoveDown size='1rem' className='icon-primary' />}
onClick={() => handleMoveDown(getItemID(props.row.original))}
/>
</div>
)
})
];
return (
<div className={cn('flex flex-col gap-1 border-t border-x rounded-md bg-input', className)} {...restProps}>
<ComboBox
noBorder
items={items}
value={lastSelected}
placeholder='Выберите операцию или блок'
idFunc={item => String(getItemID(item))}
labelValueFunc={item => labelOssItem(item)}
labelOptionFunc={item => labelOssItem(item)}
onChange={handleSelect}
/>
<DataTable
dense
noFooter
rows={rows}
contentHeight='1.3rem'
className='cc-scroll-y text-sm select-none border-y rounded-b-md'
data={selectedItems}
columns={columns}
headPosition='0rem'
noDataComponent={
<NoData>
<p>Список пуст</p>
</NoData>
}
/>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { ComboBox } from '@/components/input/combo-box';
import { type Styling } from '@/components/props';
import { type IBlock } from '../models/oss';
interface SelectBlockProps extends Styling {
id?: string;
value: IBlock | null;
onChange: (newValue: IBlock | null) => void;
items?: IBlock[];
placeholder?: string;
noBorder?: boolean;
popoverClassname?: string;
}
export function SelectBlock({ items, placeholder = 'Выберите блок', ...restProps }: SelectBlockProps) {
return (
<ComboBox
items={items}
clearable
placeholder={placeholder}
idFunc={block => String(block.id)}
labelValueFunc={block => block.title}
labelOptionFunc={block => block.title}
{...restProps}
/>
);
}

View File

@ -0,0 +1,35 @@
import clsx from 'clsx';
import { IconConceptBlock } from '@/components/icons';
import { globalIDs } from '@/utils/constants';
import { type IBlock } from '../models/oss';
import { SelectBlock } from './select-block';
interface SelectParentProps {
id?: string;
value: IBlock | null;
onChange: (newValue: IBlock | null) => void;
fullWidth?: boolean;
items?: IBlock[];
placeholder?: string;
noBorder?: boolean;
popoverClassname?: string;
}
export function SelectParent({ fullWidth, ...restProps }: SelectParentProps) {
return (
<div className={clsx('flex gap-2 items-center', !fullWidth ? 'w-80' : 'w-full')}>
<IconConceptBlock
tabIndex={-1}
size='2rem'
className='text-primary min-w-8'
data-tooltip-id={globalIDs.tooltip}
data-tooltip-content='Родительский блок содержания'
/>
<SelectBlock className={fullWidth ? 'grow' : 'w-70'} {...restProps} />
</div>
);
}

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