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", "fractalbrew.backticks",
"streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker",
"streetsidesoftware.code-spell-checker-russian", "streetsidesoftware.code-spell-checker-russian",
"kamikillerto.vscode-colorize",
"batisteo.vscode-django", "batisteo.vscode-django",
"ms-azuretools.vscode-docker", "ms-azuretools.vscode-docker",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"seyyedkhandon.firacode", "seyyedkhandon.firacode",
"nize.oklch-preview",
"ms-python.isort", "ms-python.isort",
"ms-vscode.powershell", "ms-vscode.powershell",
"esbenp.prettier-vscode", "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> <summary>VS Code plugins</summary>
<pre> <pre>
- ESLint - ESLint
- Colorize - Oklch Color Preview
- Tailwind CSS IntelliSense - Tailwind CSS IntelliSense
- Code Spell Checker (eng + rus) - Code Spell Checker (eng + rus)
- Backticks - Backticks

View File

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

View File

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

View File

@ -17,7 +17,7 @@ class PropagationFacade:
@staticmethod @staticmethod
def after_create_cst(source: RSForm, new_cst: list[Constituenta], exclude: Optional[list[int]] = None) -> None: 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) hosts = _get_oss_hosts(source.model)
for host in hosts: for host in hosts:
if exclude is None or host.pk not in exclude: if exclude is None or host.pk not in exclude:

View File

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

View File

@ -1,4 +1,5 @@
''' Serializers for persistent data manipulation. ''' ''' Serializers for persistent data manipulation. '''
from collections import deque
from typing import cast from typing import cast
from django.db.models import F from django.db.models import F
@ -41,7 +42,7 @@ class ArgumentSerializer(serializers.ModelSerializer):
fields = ('operation', 'argument') fields = ('operation', 'argument')
class BlockCreateSerializer(serializers.Serializer): class CreateBlockSerializer(serializers.Serializer):
''' Serializer: Block creation. ''' ''' Serializer: Block creation. '''
class BlockCreateData(serializers.ModelSerializer): class BlockCreateData(serializers.ModelSerializer):
''' Serializer: Block creation data. ''' ''' Serializer: Block creation data. '''
@ -52,7 +53,6 @@ class BlockCreateSerializer(serializers.Serializer):
fields = 'title', 'description', 'parent' fields = 'title', 'description', 'parent'
layout = LayoutSerializer() layout = LayoutSerializer()
item_data = BlockCreateData() item_data = BlockCreateData()
width = serializers.FloatField() width = serializers.FloatField()
height = serializers.FloatField() height = serializers.FloatField()
@ -63,9 +63,10 @@ class BlockCreateSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and \ parent = attrs['item_data'].get('parent')
attrs['item_data']['parent'] is not None and \ children_blocks = attrs.get('children_blocks', [])
attrs['item_data']['parent'].oss_id != oss.pk:
if parent is not None and parent.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
@ -76,17 +77,114 @@ class BlockCreateSerializer(serializers.Serializer):
'children_operations': msg.childNotInOSS() 'children_operations': msg.childNotInOSS()
}) })
for block in attrs['children_blocks']: for block in children_blocks:
if block.oss_id != oss.pk: if block.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'children_blocks': msg.childNotInOSS() '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 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. ''' ''' Serializer: Operation creation. '''
class OperationCreateData(serializers.ModelSerializer): class CreateOperationData(serializers.ModelSerializer):
''' Serializer: Operation creation data. ''' ''' Serializer: Operation creation data. '''
alias = serializers.CharField() alias = serializers.CharField()
operation_type = serializers.ChoiceField(OperationType.choices) operation_type = serializers.ChoiceField(OperationType.choices)
@ -99,8 +197,7 @@ class OperationCreateSerializer(serializers.Serializer):
'description', 'result', 'parent' 'description', 'result', 'parent'
layout = LayoutSerializer() layout = LayoutSerializer()
item_data = CreateOperationData()
item_data = OperationCreateData()
position_x = serializers.FloatField() position_x = serializers.FloatField()
position_y = serializers.FloatField() position_y = serializers.FloatField()
create_schema = serializers.BooleanField(default=False, required=False) create_schema = serializers.BooleanField(default=False, required=False)
@ -108,9 +205,8 @@ class OperationCreateSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
if 'parent' in attrs['item_data'] and \ parent = attrs['item_data'].get('parent')
attrs['item_data']['parent'] is not None and \ if parent is not None and parent.oss_id != oss.pk:
attrs['item_data']['parent'].oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
@ -120,23 +216,23 @@ class OperationCreateSerializer(serializers.Serializer):
for operation in attrs['arguments']: for operation in attrs['arguments']:
if operation.oss_id != oss.pk: if operation.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'arguments': msg.operationNotInOSS(oss.title) 'arguments': msg.operationNotInOSS()
}) })
return attrs return attrs
class OperationUpdateSerializer(serializers.Serializer): class UpdateOperationSerializer(serializers.Serializer):
''' Serializer: Operation update. ''' ''' Serializer: Operation update. '''
class OperationUpdateData(serializers.ModelSerializer): class UpdateOperationData(serializers.ModelSerializer):
''' Serializer: Operation update data. ''' ''' Serializer: Operation update data. '''
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = Operation 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()) 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) arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
substitutions = serializers.ListField( substitutions = serializers.ListField(
child=SubstitutionSerializerBase(), child=SubstitutionSerializerBase(),
@ -145,7 +241,14 @@ class OperationUpdateSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) 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({ raise serializers.ValidationError({
'parent': msg.parentNotInOSS() 'parent': msg.parentNotInOSS()
}) })
@ -160,7 +263,7 @@ class OperationUpdateSerializer(serializers.Serializer):
for operation in attrs['arguments']: for operation in attrs['arguments']:
if operation.oss_id != oss.pk: if operation.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'arguments': msg.operationNotInOSS(oss.title) 'arguments': msg.operationNotInOSS()
}) })
if 'substitutions' not in attrs: if 'substitutions' not in attrs:
@ -192,22 +295,7 @@ class OperationUpdateSerializer(serializers.Serializer):
return attrs return attrs
class OperationTargetSerializer(serializers.Serializer): class DeleteOperationSerializer(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):
''' Serializer: Delete operation. ''' ''' Serializer: Delete operation. '''
layout = LayoutSerializer() layout = LayoutSerializer()
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result')) target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result'))
@ -217,9 +305,24 @@ class OperationDeleteSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
oss = cast(LibraryItem, self.context['oss']) oss = cast(LibraryItem, self.context['oss'])
operation = cast(Operation, attrs['target']) operation = cast(Operation, attrs['target'])
if oss and operation.oss_id != oss.pk: if operation.oss_id != oss.pk:
raise serializers.ValidationError({ 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 return attrs
@ -240,7 +343,7 @@ class SetOperationInputSerializer(serializers.Serializer):
operation = cast(Operation, attrs['target']) operation = cast(Operation, attrs['target'])
if oss and operation.oss_id != oss.pk: if oss and operation.oss_id != oss.pk:
raise serializers.ValidationError({ raise serializers.ValidationError({
'target': msg.operationNotInOSS(oss.title) 'target': msg.operationNotInOSS()
}) })
if operation.operation_type != OperationType.INPUT: if operation.operation_type != OperationType.INPUT:
raise serializers.ValidationError({ raise serializers.ValidationError({
@ -355,3 +458,29 @@ class RelocateConstituentsSerializer(serializers.Serializer):
}) })
return attrs 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 from .data_access import BlockSerializer, OperationSchemaSerializer, OperationSerializer
class NewOperationResponse(serializers.Serializer): class OperationCreatedResponse(serializers.Serializer):
''' Serializer: Create operation response. ''' ''' Serializer: Create operation response. '''
new_operation = OperationSerializer() new_operation = OperationSerializer()
oss = OperationSchemaSerializer() oss = OperationSchemaSerializer()
class NewBlockResponse(serializers.Serializer): class BlockCreatedResponse(serializers.Serializer):
''' Serializer: Create block response. ''' ''' Serializer: Create block response. '''
new_block = BlockSerializer() new_block = BlockSerializer()
oss = OperationSchemaSerializer() oss = OperationSchemaSerializer()
class NewSchemaResponse(serializers.Serializer): class SchemaCreatedResponse(serializers.Serializer):
''' Serializer: Create RSForm for input operation response. ''' ''' Serializer: Create RSForm for input operation response. '''
new_schema = LibraryItemSerializer() new_schema = LibraryItemSerializer()
oss = OperationSchemaSerializer() oss = OperationSchemaSerializer()

View File

@ -27,6 +27,10 @@ class TestOssBlocks(EndpointTester):
self.block1 = self.owned.create_block( self.block1 = self.owned.create_block(
title='1', title='1',
) )
self.block2 = self.owned.create_block(
title='2',
parent=self.block1
)
self.operation1 = self.owned.create_operation( self.operation1 = self.owned.create_operation(
alias='1', alias='1',
operation_type=OperationType.INPUT, operation_type=OperationType.INPUT,
@ -35,15 +39,12 @@ class TestOssBlocks(EndpointTester):
self.operation2 = self.owned.create_operation( self.operation2 = self.owned.create_operation(
alias='2', alias='2',
operation_type=OperationType.INPUT, operation_type=OperationType.INPUT,
parent=self.block2,
) )
self.operation3 = self.unowned.create_operation( self.operation3 = self.unowned.create_operation(
alias='3', alias='3',
operation_type=OperationType.INPUT operation_type=OperationType.INPUT
) )
self.block2 = self.owned.create_block(
title='2',
parent=self.block1
)
self.block3 = self.unowned.create_block( self.block3 = self.unowned.create_block(
title='3', title='3',
parent=self.block1 parent=self.block1
@ -165,3 +166,99 @@ class TestOssBlocks(EndpointTester):
self.block1.refresh_from_db() self.block1.refresh_from_db()
self.assertEqual(self.operation1.parent.pk, new_block['id']) self.assertEqual(self.operation1.parent.pk, new_block['id'])
self.assertEqual(self.block1.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') @decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_operation(self): def test_delete_operation(self):
self.executeNotFound(item=self.invalid_id)
self.populateData() self.populateData()
self.executeNotFound(item=self.invalid_id)
self.executeBadData(item=self.owned_id) self.executeBadData(item=self.owned_id)
data = { data = {
@ -371,7 +370,6 @@ class TestOssOperations(EndpointTester):
'title': 'Test title mod', 'title': 'Test title mod',
'description': 'Comment mod' 'description': 'Comment mod'
}, },
'layout': self.layout_data,
'arguments': [self.operation2.pk, self.operation1.pk], 'arguments': [self.operation2.pk, self.operation1.pk],
'substitutions': [ 'substitutions': [
{ {
@ -403,6 +401,10 @@ class TestOssOperations(EndpointTester):
self.assertEqual(sub.original.pk, data['substitutions'][0]['original']) self.assertEqual(sub.original.pk, data['substitutions'][0]['original'])
self.assertEqual(sub.substitution.pk, data['substitutions'][0]['substitution']) 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') @decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_sync(self): def test_update_operation_sync(self):
self.populateData() self.populateData()

View File

@ -55,12 +55,13 @@ class TestOssViewset(EndpointTester):
alias='3', alias='3',
operation_type=OperationType.SYNTHESIS operation_type=OperationType.SYNTHESIS
) )
layout = self.owned.layout() self.layout_data = {'operations': [
layout.data = {'operations': [
{'id': self.operation1.pk, 'x': 0, 'y': 0}, {'id': self.operation1.pk, 'x': 0, 'y': 0},
{'id': self.operation2.pk, 'x': 0, 'y': 0}, {'id': self.operation2.pk, 'x': 0, 'y': 0},
{'id': self.operation3.pk, 'x': 0, 'y': 0}, {'id': self.operation3.pk, 'x': 0, 'y': 0},
], 'blocks': []} ], 'blocks': []}
layout = self.owned.layout()
layout.data = self.layout_data
layout.save() layout.save()
self.owned.set_arguments(self.operation3.pk, [self.operation1, self.operation2]) 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['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.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') @decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents(self): def test_relocate_constituents(self):
self.populateData() self.populateData()

View File

@ -37,12 +37,15 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
''' Determine permission class. ''' ''' Determine permission class. '''
if self.action in [ if self.action in [
'update_layout', 'update_layout',
'create_operation',
'create_block', 'create_block',
'update_block',
'delete_block',
'move_items',
'create_operation',
'update_operation',
'delete_operation', 'delete_operation',
'create_input', 'create_input',
'set_input', 'set_input',
'update_operation',
'execute_operation', 'execute_operation',
'relocate_constituents' 'relocate_constituents'
]: ]:
@ -91,12 +94,168 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
m.OperationSchema(self.get_object()).update_layout(serializer.validated_data) m.OperationSchema(self.get_object()).update_layout(serializer.validated_data)
return Response(status=c.HTTP_200_OK) return Response(status=c.HTTP_200_OK)
@extend_schema(
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( @extend_schema(
summary='create operation', summary='create operation',
tags=['OSS'], tags=['OSS'],
request=s.OperationCreateSerializer(), request=s.CreateOperationSerializer(),
responses={ responses={
c.HTTP_201_CREATED: s.NewOperationResponse, c.HTTP_201_CREATED: s.OperationCreatedResponse,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None, c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: 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') @action(detail=True, methods=['post'], url_path='create-operation')
def create_operation(self, request: Request, pk) -> HttpResponse: def create_operation(self, request: Request, pk) -> HttpResponse:
''' Create new operation. ''' ''' Create Operation. '''
serializer = s.OperationCreateSerializer( serializer = s.CreateOperationSerializer(
data=request.data, data=request.data,
context={'oss': self.get_object()} context={'oss': self.get_object()}
) )
@ -153,60 +312,58 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
) )
@extend_schema( @extend_schema(
summary='create block', summary='update operation',
tags=['OSS'], tags=['OSS'],
request=s.BlockCreateSerializer(), request=s.UpdateOperationSerializer(),
responses={ responses={
c.HTTP_201_CREATED: s.NewBlockResponse, c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None, c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None c.HTTP_404_NOT_FOUND: None
} }
) )
@action(detail=True, methods=['post'], url_path='create-block') @action(detail=True, methods=['patch'], url_path='update-operation')
def create_block(self, request: Request, pk) -> HttpResponse: def update_operation(self, request: Request, pk) -> HttpResponse:
''' Create new block. ''' ''' Update Operation arguments and parameters. '''
serializer = s.BlockCreateSerializer( serializer = s.UpdateOperationSerializer(
data=request.data, data=request.data,
context={'oss': self.get_object()} context={'oss': self.get_object()}
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
oss = m.OperationSchema(self.get_object()) 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(): with transaction.atomic():
new_block = oss.create_block(**serializer.validated_data['item_data']) if 'layout' in serializer.validated_data:
layout['blocks'].append({ oss.update_layout(serializer.validated_data['layout'])
'id': new_block.pk, if 'alias' in serializer.validated_data['item_data']:
'x': serializer.validated_data['position_x'], operation.alias = serializer.validated_data['item_data']['alias']
'y': serializer.validated_data['position_y'], if 'title' in serializer.validated_data['item_data']:
'width': serializer.validated_data['width'], operation.title = serializer.validated_data['item_data']['title']
'height': serializer.validated_data['height'], if 'description' in serializer.validated_data['item_data']:
}) operation.description = serializer.validated_data['item_data']['description']
oss.update_layout(layout) operation.save(update_fields=['alias', 'title', 'description'])
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 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( return Response(
status=c.HTTP_201_CREATED, status=c.HTTP_200_OK,
data={ data=s.OperationSchemaSerializer(oss.model).data
'new_block': s.BlockSerializer(new_block).data,
'oss': s.OperationSchemaSerializer(oss.model).data
}
) )
@extend_schema( @extend_schema(
summary='delete operation', summary='delete operation',
tags=['OSS'], tags=['OSS'],
request=s.OperationDeleteSerializer, request=s.DeleteOperationSerializer,
responses={ responses={
c.HTTP_200_OK: s.OperationSchemaSerializer, c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None, 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') @action(detail=True, methods=['patch'], url_path='delete-operation')
def delete_operation(self, request: Request, pk) -> HttpResponse: def delete_operation(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete operation. ''' ''' Endpoint: Delete Operation. '''
serializer = s.OperationDeleteSerializer( serializer = s.DeleteOperationSerializer(
data=request.data, data=request.data,
context={'oss': self.get_object()} context={'oss': self.get_object()}
) )
@ -246,9 +403,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
@extend_schema( @extend_schema(
summary='create input schema for target operation', summary='create input schema for target operation',
tags=['OSS'], tags=['OSS'],
request=s.OperationTargetSerializer(), request=s.TargetOperationSerializer(),
responses={ responses={
c.HTTP_200_OK: s.NewSchemaResponse, c.HTTP_200_OK: s.SchemaCreatedResponse,
c.HTTP_400_BAD_REQUEST: None, c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None, c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: 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') @action(detail=True, methods=['patch'], url_path='create-input')
def create_input(self, request: Request, pk) -> HttpResponse: def create_input(self, request: Request, pk) -> HttpResponse:
''' Create new input RSForm. ''' ''' Create input RSForm. '''
serializer = s.OperationTargetSerializer( serializer = s.TargetOperationSerializer(
data=request.data, data=request.data,
context={'oss': self.get_object()} context={'oss': self.get_object()}
) )
@ -333,55 +490,10 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
data=s.OperationSchemaSerializer(oss.model).data 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( @extend_schema(
summary='execute operation', summary='execute operation',
tags=['OSS'], tags=['OSS'],
request=s.OperationTargetSerializer(), request=s.TargetOperationSerializer(),
responses={ responses={
c.HTTP_200_OK: s.OperationSchemaSerializer, c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None, 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') @action(detail=True, methods=['post'], url_path='execute-operation')
def execute_operation(self, request: Request, pk) -> HttpResponse: def execute_operation(self, request: Request, pk) -> HttpResponse:
''' Execute operation. ''' ''' Execute operation. '''
serializer = s.OperationTargetSerializer( serializer = s.TargetOperationSerializer(
data=request.data, data=request.data,
context={'oss': self.get_object()} context={'oss': self.get_object()}
) )

View File

@ -137,7 +137,7 @@ class RSForm:
return result return result
def create_cst(self, data: dict, insert_after: Optional[Constituenta] = None) -> Constituenta: 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: if insert_after is None:
position = INSERT_LAST position = INSERT_LAST
else: else:

View File

@ -77,7 +77,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@action(detail=True, methods=['post'], url_path='create-cst') @action(detail=True, methods=['post'], url_path='create-cst')
def create_cst(self, request: Request, pk) -> HttpResponse: def create_cst(self, request: Request, pk) -> HttpResponse:
''' Create new constituenta. ''' ''' Create Constituenta. '''
serializer = s.CstCreateSerializer(data=request.data) serializer = s.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data 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') @action(detail=True, methods=['patch'], url_path='delete-multiple-cst')
def delete_multiple_cst(self, request: Request, pk) -> HttpResponse: def delete_multiple_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Delete multiple constituents. ''' ''' Endpoint: Delete multiple Constituents. '''
model = self._get_item() model = self._get_item()
serializer = s.CstListSerializer( serializer = s.CstListSerializer(
data=request.data, data=request.data,
@ -284,7 +284,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@action(detail=True, methods=['patch'], url_path='move-cst') @action(detail=True, methods=['patch'], url_path='move-cst')
def move_cst(self, request: Request, pk) -> HttpResponse: def move_cst(self, request: Request, pk) -> HttpResponse:
''' Endpoint: Move multiple constituents. ''' ''' Endpoint: Move multiple Constituents. '''
model = self._get_item() model = self._get_item()
serializer = s.CstMoveSerializer( serializer = s.CstMoveSerializer(
data=request.data, data=request.data,
@ -334,7 +334,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@action(detail=True, methods=['patch'], url_path='restore-order') @action(detail=True, methods=['patch'], url_path='restore-order')
def restore_order(self, request: Request, pk) -> HttpResponse: 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() model = self._get_item()
m.RSForm(model).restore_order() m.RSForm(model).restore_order()
return Response( return Response(
@ -449,7 +449,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@action(detail=True, methods=['post'], url_path='check-constituenta') @action(detail=True, methods=['post'], url_path='check-constituenta')
def check_constituenta(self, request: Request, pk) -> HttpResponse: 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 = s.ConstituentaCheckSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['definition_formal'] 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') @action(detail=True, methods=['post'], url_path='resolve')
def resolve(self, request: Request, pk) -> HttpResponse: 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 = s.TextSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
text = serializer.validated_data['text'] text = serializer.validated_data['text']
@ -543,7 +543,7 @@ class TrsImportView(views.APIView):
@extend_schema( @extend_schema(
summary='create new RSForm empty or from file', summary='create RSForm empty or from file',
tags=['RSForm'], tags=['RSForm'],
request=LibraryItemSerializer, request=LibraryItemSerializer,
responses={ responses={

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import clsx from 'clsx';
import { useMutationErrors } from '@/backend/use-mutation-errors'; import { useMutationErrors } from '@/backend/use-mutation-errors';
import { Button } from '@/components/control'; import { Button } from '@/components/control';
import { DescribeError } from '@/components/info-error'; import { DescribeError } from '@/components/info-error';
@ -20,9 +22,23 @@ export function MutationErrors() {
return ( return (
<div className='cc-modal-wrapper'> <div className='cc-modal-wrapper'>
<ModalBackdrop onHide={resetErrors} /> <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> <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]} /> <DescribeError error={mutationErrors[0]} />
</div> </div>
<Button onClick={resetErrors} className='w-fit' text='Закрыть' /> <Button onClick={resetErrors} className='w-fit' text='Закрыть' />

View File

@ -34,7 +34,7 @@ export function Navigation() {
<div <div
className={clsx( className={clsx(
'pl-2 sm:pr-4 h-12 flex cc-shadow-border', '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' noNavigationAnimation ? '-translate-y-6 max-h-0' : 'max-h-12'
)} )}
> >

View File

@ -4,9 +4,7 @@
import { buildConstants } from '@/utils/build-constants'; import { buildConstants } from '@/utils/build-constants';
/** /** Routes. */
* Routes.
*/
export const routes = { export const routes = {
not_found: 'not-found', not_found: 'not-found',
login: 'login', login: 'login',
@ -22,11 +20,9 @@ export const routes = {
oss: 'oss', oss: 'oss',
icons: 'icons', icons: 'icons',
database_schema: 'database-schema' database_schema: 'database-schema'
}; } as const;
/** /** Internal navigation URLs. */
* Internal navigation URLs.
*/
export const urls = { export const urls = {
page404: '/not-found', page404: '/not-found',
admin: `${buildConstants.backend}/admin`, admin: `${buildConstants.backend}/admin`,
@ -66,4 +62,4 @@ export const urls = {
oss_props: ({ id, tab }: { id: number | string; tab: number }) => { oss_props: ({ id, tab }: { id: number | string; tab: number }) => {
return `/oss/${id}?tab=${tab}`; 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 { buildConstants } from '@/utils/build-constants';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { extractErrorMessage } from '@/utils/utils'; import { extractErrorMessage } from '@/utils/utils';
export { AxiosError } from 'axios'; export { AxiosError } from 'axios';
@ -58,7 +59,7 @@ export function axiosGet<ResponseData>({ endpoint, options, schema }: IAxiosGetR
.get<ResponseData>(endpoint, options) .get<ResponseData>(endpoint, options)
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
// Note: Ignore cancellation errors // Note: Ignore cancellation errors
@ -81,7 +82,7 @@ export function axiosPost<RequestData, ResponseData = void>({
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage); notifySuccess(response.data, request?.successMessage);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error); notifyError(error);
@ -100,7 +101,7 @@ export function axiosDelete<RequestData, ResponseData = void>({
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage); notifySuccess(response.data, request?.successMessage);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error); notifyError(error);
@ -119,7 +120,7 @@ export function axiosPatch<RequestData, ResponseData = void>({
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage); notifySuccess(response.data, request?.successMessage);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error); notifyError(error);

View File

@ -6,7 +6,7 @@ export const DELAYS = {
staleShort: 5 * 60 * 1000, staleShort: 5 * 60 * 1000,
staleMedium: 1 * 60 * 60 * 1000, staleMedium: 1 * 60 * 60 * 1000,
staleLong: 24 * 60 * 60 * 1000 staleLong: 24 * 60 * 60 * 1000
}; } as const;
/** API keys for local cache. */ /** API keys for local cache. */
export const KEYS = { export const KEYS = {
@ -19,8 +19,8 @@ export const KEYS = {
global_mutation: 'global_mutation', global_mutation: 'global_mutation',
composite: { composite: {
libraryList: ['library', 'list'], libraryList: ['library', 'list'] as const,
ossItem: ({ itemID }: { itemID?: number }) => [KEYS.oss, 'item', itemID], ossItem: ({ itemID }: { itemID?: number }) => [KEYS.oss, 'item', itemID],
rsItem: ({ itemID, version }: { itemID?: number; version?: number }) => [KEYS.rsform, 'item', itemID, version ?? ''] 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 { TableBody } from './table-body';
import { TableFooter } from './table-footer'; import { TableFooter } from './table-footer';
import { TableHeader } from './table-header'; 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 }; 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> export interface DataTableProps<TData extends RowData>
extends Styling, extends Styling,
Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> { Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> {
@ -87,7 +78,7 @@ export interface DataTableProps<TData extends RowData>
paginationPerPage?: number; paginationPerPage?: number;
/** List of options to choose from for pagination. */ /** List of options to choose from for pagination. */
paginationOptions?: number[]; paginationOptions?: readonly number[];
/** Callback to be called when the pagination option is changed. */ /** Callback to be called when the pagination option is changed. */
onChangePaginationOption?: (newValue: number) => void; onChangePaginationOption?: (newValue: number) => void;

View File

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

View File

@ -1,17 +1,16 @@
'use no memo'; 'use no memo';
'use client'; 'use client';
import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table'; import { type Table } from '@tanstack/react-table';
import { prefixes } from '@/utils/constants';
import { IconPageFirst, IconPageLast, IconPageLeft, IconPageRight } from '../icons'; import { IconPageFirst, IconPageLast, IconPageLeft, IconPageRight } from '../icons';
import { SelectPagination } from './select-pagination';
interface PaginationToolsProps<TData> { interface PaginationToolsProps<TData> {
id?: string; id?: string;
table: Table<TData>; table: Table<TData>;
paginationOptions: number[]; paginationOptions: readonly number[];
onChangePaginationOption?: (newValue: number) => void; onChangePaginationOption?: (newValue: number) => void;
} }
@ -21,15 +20,6 @@ export function PaginationTools<TData>({
onChangePaginationOption, onChangePaginationOption,
paginationOptions paginationOptions
}: PaginationToolsProps<TData>) { }: PaginationToolsProps<TData>) {
const handlePaginationOptionsChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const perPage = Number(event.target.value);
table.setPageSize(perPage);
onChangePaginationOption?.(perPage);
},
[table, onChangePaginationOption]
);
return ( return (
<div className='flex justify-end items-center my-2 text-sm cc-controls select-none'> <div className='flex justify-end items-center my-2 text-sm cc-controls select-none'>
<span className='mr-3'> <span className='mr-3'>
@ -93,19 +83,12 @@ export function PaginationTools<TData>({
<IconPageLast size='1.5rem' /> <IconPageLast size='1.5rem' />
</button> </button>
</div> </div>
<select <SelectPagination
id={id ? `${id}__per_page` : undefined} id={id ? `${id}__per_page` : undefined}
aria-label='Выбор количества строчек на странице' table={table}
value={table.getState().pagination.pageSize} paginationOptions={paginationOptions}
onChange={handlePaginationOptionsChange} onChange={onChangePaginationOption}
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>
</div> </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 no memo';
'use client';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { type Cell, flexRender, type Row, type Table } from '@tanstack/react-table'; import { type Row, type Table } from '@tanstack/react-table';
import clsx from 'clsx';
import { SelectRow } from './select-row'; import { TableRow } from './table-row';
import { type IConditionalStyle } from '.'; import { type IConditionalStyle } from './use-data-table';
interface TableBodyProps<TData> { interface TableBodyProps<TData> {
table: Table<TData>; table: Table<TData>;
@ -30,82 +30,43 @@ export function TableBody<TData>({
onRowClicked, onRowClicked,
onRowDoubleClicked onRowDoubleClicked
}: TableBodyProps<TData>) { }: TableBodyProps<TData>) {
const handleRowClicked = useCallback( const getRowStyles = useCallback(
(target: Row<TData>, event: React.MouseEvent<Element>) => { (row: Row<TData>) =>
onRowClicked?.(target.original, event); conditionalRowStyles
if (table.options.enableRowSelection && target.getCanSelect()) { ?.filter(item => !!item.style && item.when(row.original))
if (event.shiftKey && !!lastSelected && lastSelected !== target.id) { ?.reduce((prev, item) => ({ ...prev, ...item.style }), {}),
const { rows, rowsById } = table.getRowModel();
const lastIndex = rowsById[lastSelected].index; [conditionalRowStyles]
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( const getRowClasses = useCallback(
(row: Row<TData>) => { (row: Row<TData>) => {
return { return conditionalRowStyles
...conditionalRowStyles! ?.filter(item => !!item.className && item.when(row.original))
.filter(item => item.when(row.original)) ?.reduce((prev, item) => {
.reduce((prev, item) => ({ ...prev, ...item.style }), {}) prev.push(item.className!);
}; return prev;
}, [] as string[]);
}, },
[conditionalRowStyles] [conditionalRowStyles]
); );
return ( return (
<tbody> <tbody>
{table.getRowModel().rows.map((row: Row<TData>, index) => ( {table.getRowModel().rows.map((row: Row<TData>) => (
<tr <TableRow
key={row.id} key={row.id}
className={clsx( table={table}
'cc-scroll-row', row={row}
'cc-hover cc-animate-background duration-(--duration-fade)', className={getRowClasses(row)?.join(' ')}
!noHeader && 'scroll-mt-[calc(2px+2rem)]', style={conditionalRowStyles ? { ...getRowStyles(row) } : undefined}
table.options.enableRowSelection && row.getIsSelected() noHeader={noHeader}
? 'cc-selected' dense={dense}
: 'odd:bg-secondary even:bg-background' lastSelected={lastSelected}
)} onChangeLastSelected={onChangeLastSelected}
style={{ ...(conditionalRowStyles ? getRowStyles(row) : []) }} onRowClicked={onRowClicked}
onClick={event => handleRowClicked(row, event)} onRowDoubleClicked={onRowDoubleClicked}
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>
))} ))}
</tbody> </tbody>
); );

View File

@ -1,4 +1,5 @@
'use no memo'; 'use no memo';
'use client';
import { flexRender, type Header, type HeaderGroup, type Table } from '@tanstack/react-table'; import { flexRender, type Header, type HeaderGroup, type Table } from '@tanstack/react-table';
import clsx from 'clsx'; 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; when: (rowData: TData) => boolean;
/** Style to apply. */ /** Style to apply. */
style: React.CSSProperties; style?: React.CSSProperties;
/** Classname to apply. */
className?: string;
} }
interface UseDataTableProps<TData extends RowData> 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 { BiX as IconRemove } from 'react-icons/bi';
export { BiTrash as IconDestroy } from 'react-icons/bi'; export { BiTrash as IconDestroy } from 'react-icons/bi';
export { BiReset as IconReset } 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 { LiaEdit as IconEdit } from 'react-icons/lia';
export { FiEdit as IconEdit2 } from 'react-icons/fi'; export { FiEdit as IconEdit2 } from 'react-icons/fi';
export { BiSearchAlt2 as IconSearch } from 'react-icons/bi'; export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi'; export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } 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 { TbEye as IconShow } from 'react-icons/tb';
export { TbEyeX as IconHide } from 'react-icons/tb'; export { TbEyeX as IconHide } from 'react-icons/tb';
export { BiShareAlt as IconShare } from 'react-icons/bi'; export { BiShareAlt as IconShare } from 'react-icons/bi';
@ -68,9 +69,12 @@ export { LuGlasses as IconReader } from 'react-icons/lu';
// ===== Domain entities ======= // ===== Domain entities =======
export { TbBriefcase as IconBusiness } from 'react-icons/tb'; export { TbBriefcase as IconBusiness } from 'react-icons/tb';
export { VscLibrary as IconLibrary } from 'react-icons/vsc'; 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 { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi'; export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { TbHexagons as IconOSS } from 'react-icons/tb'; 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 { TbHexagon as IconRSForm } from 'react-icons/tb';
export { TbAssembly as IconRSFormOwned } from 'react-icons/tb'; export { TbAssembly as IconRSFormOwned } from 'react-icons/tb';
export { TbBallFootball as IconRSFormImported } 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 { TbMathFunction as IconFormula } from 'react-icons/tb';
export { BiFontFamily as IconText } from 'react-icons/bi'; export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi'; export { BiFont as IconTextOff } from 'react-icons/bi';
export { TbCar4Wd as IconTypeGraph } from 'react-icons/tb'; export { TbCircleLetterM as IconTypeGraph } from 'react-icons/tb';
export { RiTreeLine as IconTree } from 'react-icons/ri'; export { RiTreeLine as IconTree } from 'react-icons/ri';
export { FaRegKeyboard as IconControls } from 'react-icons/fa6'; export { FaRegKeyboard as IconControls } from 'react-icons/fa6';
export { RiLockLine as IconImmutable } from 'react-icons/ri'; 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'; export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI ======= // ======== Graph UI =======
export { LuLayoutDashboard as IconFixLayout } from 'react-icons/lu';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi'; export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi'; export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu'; export { LuMaximize as IconGraphMaximize } from 'react-icons/lu';
@ -200,7 +205,7 @@ export function IconLogin(props: IconProps) {
export function CheckboxChecked() { export function CheckboxChecked() {
return ( 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' /> <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> </svg>
); );
@ -208,7 +213,7 @@ export function CheckboxChecked() {
export function CheckboxNull() { export function CheckboxNull() {
return ( 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' /> <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> </svg>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { APP_COLORS } from '@/styling/colors';
interface LoaderProps { interface LoaderProps {
/** Scale of the loader from 1 to 10. */ /** Scale of the loader from 1 to 10. */
scale?: number; scale?: number;
@ -57,8 +55,8 @@ const animatePulse = (startBig: boolean, duration: string) => {
export function Loader({ scale = 5, circular }: LoaderProps) { export function Loader({ scale = 5, circular }: LoaderProps) {
if (circular) { if (circular) {
return ( return (
<div className='flex justify-center' aria-label='three-circles-loading' aria-busy='true' role='progressbar'> <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={APP_COLORS.bgPrimary}> <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'> <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')} {animateRotation('2.25s')}
</path> </path>
@ -73,8 +71,8 @@ export function Loader({ scale = 5, circular }: LoaderProps) {
); );
} else { } else {
return ( return (
<div className='flex justify-center' aria-busy='true' role='progressbar'> <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={APP_COLORS.bgPrimary}> <svg height={`${scale * 20}`} width={`${scale * 20}`} viewBox='0 0 120 30' fill='currentColor'>
<circle cx='15' cy='15' r='16'> <circle cx='15' cy='15' r='16'>
{animatePulse(true, '0.8s')} {animatePulse(true, '0.8s')}
</circle> </circle>

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@ export function LoginPage() {
} }
return ( return (
<form <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)} onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors} onChange={resetErrors}
> >

View File

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

View File

@ -32,7 +32,7 @@ export function Component() {
); );
} else { } else {
return ( 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 <TextInput
id='email' id='email'
autoComplete='email' autoComplete='email'

View File

@ -51,7 +51,7 @@ export function BadgeHelp({ topic, padding = 'p-1', className, contentClass, sty
{...restProps} {...restProps}
> >
<Suspense fallback={<Loader />}> <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}`} /> <TextURL text='Справка...' href={`/manuals?topic=${topic}`} />
</div> </div>
<TopicPage topic={topic} /> <TopicPage topic={topic} />

View File

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

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
getLabel={labelHelpTopic} getLabel={labelHelpTopic}
getDescription={describeHelpTopic} getDescription={describeHelpTopic}
className={clsx( 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' menu.isOpen && 'open'
)} )}
style={{ maxHeight: treeHeight }} style={{ maxHeight: treeHeight }}

View File

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

View File

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

View File

@ -16,7 +16,7 @@ export function Component() {
}, [hideFooter]); }, [hideFooter]);
return ( 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> <TransformWrapper>
<TransformComponent> <TransformComponent>
<img alt='Схема базы данных' src={resources.db_schema} className='w-fit h-fit' /> <img alt='Схема базы данных' src={resources.db_schema} className='w-fit h-fit' />

View File

@ -15,12 +15,12 @@ import {
type AccessPolicy, type AccessPolicy,
type ICloneLibraryItemDTO, type ICloneLibraryItemDTO,
type ICreateLibraryItemDTO, type ICreateLibraryItemDTO,
type ICreateVersionDTO,
type ILibraryItem, type ILibraryItem,
type IRenameLocationDTO, type IRenameLocationDTO,
type IUpdateLibraryItemDTO, type IUpdateLibraryItemDTO,
type IVersionCreateDTO, type IUpdateVersionDTO,
type IVersionExInfo, type IVersionExInfo,
type IVersionUpdateDTO,
schemaLibraryItem, schemaLibraryItem,
schemaLibraryItemArray, schemaLibraryItemArray,
schemaVersionExInfo schemaVersionExInfo
@ -136,8 +136,8 @@ export const libraryApi = {
} }
}), }),
versionCreate: ({ itemID, data }: { itemID: number; data: IVersionCreateDTO }) => createVersion: ({ itemID, data }: { itemID: number; data: ICreateVersionDTO }) =>
axiosPost<IVersionCreateDTO, IVersionCreatedResponse>({ axiosPost<ICreateVersionDTO, IVersionCreatedResponse>({
schema: schemaVersionCreatedResponse, schema: schemaVersionCreatedResponse,
endpoint: `/api/library/${itemID}/create-version`, endpoint: `/api/library/${itemID}/create-version`,
request: { request: {
@ -145,7 +145,7 @@ export const libraryApi = {
successMessage: infoMsg.newVersion(data.version) successMessage: infoMsg.newVersion(data.version)
} }
}), }),
versionRestore: ({ versionID }: { versionID: number }) => restoreVersion: ({ versionID }: { versionID: number }) =>
axiosPatch<undefined, IRSFormDTO>({ axiosPatch<undefined, IRSFormDTO>({
schema: schemaRSForm, schema: schemaRSForm,
endpoint: `/api/versions/${versionID}/restore`, endpoint: `/api/versions/${versionID}/restore`,
@ -153,8 +153,8 @@ export const libraryApi = {
successMessage: infoMsg.versionRestored successMessage: infoMsg.versionRestored
} }
}), }),
versionUpdate: (data: { itemID: number; version: IVersionUpdateDTO }) => updateVersion: (data: { itemID: number; version: IUpdateVersionDTO }) =>
axiosPatch<IVersionUpdateDTO, IVersionExInfo>({ axiosPatch<IUpdateVersionDTO, IVersionExInfo>({
schema: schemaVersionExInfo, schema: schemaVersionExInfo,
endpoint: `/api/versions/${data.version.id}`, endpoint: `/api/versions/${data.version.id}`,
request: { request: {
@ -162,11 +162,11 @@ export const libraryApi = {
successMessage: infoMsg.changesSaved successMessage: infoMsg.changesSaved
} }
}), }),
versionDelete: (data: { itemID: number; versionID: number }) => deleteVersion: (data: { itemID: number; versionID: number }) =>
axiosDelete({ axiosDelete({
endpoint: `/api/versions/${data.versionID}`, endpoint: `/api/versions/${data.versionID}`,
request: { request: {
successMessage: infoMsg.versionDestroyed 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>; export type IUpdateLibraryItemDTO = z.infer<typeof schemaUpdateLibraryItem>;
/** Create version metadata in persistent storage. */ /** 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. */ /** 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 ========= // ======= SCHEMAS =========
export const schemaLibraryItemType = z.enum(Object.values(LibraryItemType) as [LibraryItemType, ...LibraryItemType[]]); export const schemaLibraryItemType = z.enum(Object.values(LibraryItemType) as [LibraryItemType, ...LibraryItemType[]]);
@ -140,13 +140,13 @@ export const schemaVersionExInfo = schemaVersionInfo.extend({
item: z.number() item: z.number()
}); });
export const schemaVersionUpdate = z.strictObject({ export const schemaUpdateVersion = z.strictObject({
id: z.number(), id: z.number(),
version: z.string().nonempty(errorMsg.requiredField), version: z.string().nonempty(errorMsg.requiredField),
description: z.string() description: z.string()
}); });
export const schemaVersionCreate = z.strictObject({ export const schemaCreateVersion = z.strictObject({
version: z.string(), version: z.string(),
description: z.string(), description: z.string(),
items: z.array(z.number()) items: z.array(z.number())

View File

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

View File

@ -6,11 +6,11 @@ import { KEYS } from '@/backend/configuration';
import { libraryApi } from './api'; import { libraryApi } from './api';
export const useVersionDelete = () => { export const useDeleteVersion = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-version'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-version'],
mutationFn: libraryApi.versionDelete, mutationFn: libraryApi.deleteVersion,
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
client.setQueryData(KEYS.composite.rsItem({ itemID: variables.itemID }), (prev: IRSFormDTO | undefined) => client.setQueryData(KEYS.composite.rsItem({ itemID: variables.itemID }), (prev: IRSFormDTO | undefined) =>
!prev !prev
@ -24,6 +24,6 @@ export const useVersionDelete = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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'; import { libraryApi } from './api';
export function useLibraryListKey() {
const adminMode = usePreferencesStore(state => state.adminMode);
return libraryApi.getLibraryQueryOptions({ isAdmin: adminMode }).queryKey;
}
export function useLibrarySuspense() { export function useLibrarySuspense() {
const adminMode = usePreferencesStore(state => state.adminMode); const adminMode = usePreferencesStore(state => state.adminMode);
const { user } = useAuthSuspense(); const { user } = useAuthSuspense();

View File

@ -4,11 +4,11 @@ import { KEYS } from '@/backend/configuration';
import { libraryApi } from './api'; import { libraryApi } from './api';
export const useVersionRestore = () => { export const useRestoreVersion = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'restore-version'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'restore-version'],
mutationFn: libraryApi.versionRestore, mutationFn: libraryApi.restoreVersion,
onSuccess: data => { onSuccess: data => {
client.setQueryData(KEYS.composite.rsItem({ itemID: data.id }), data); client.setQueryData(KEYS.composite.rsItem({ itemID: data.id }), data);
return client.invalidateQueries({ queryKey: [libraryApi.baseKey] }); return client.invalidateQueries({ queryKey: [libraryApi.baseKey] });
@ -16,6 +16,6 @@ export const useVersionRestore = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type AccessPolicy, type ILibraryItem } from './types'; import { type AccessPolicy, type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export const useSetAccessPolicy = () => { export const useSetAccessPolicy = () => {
const client = useQueryClient(); const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-location'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-location'],
mutationFn: libraryApi.setAccessPolicy, mutationFn: libraryApi.setAccessPolicy,
@ -36,7 +39,7 @@ export const useSetAccessPolicy = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) => client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, access_policy: variables.policy } !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)) 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 { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type ILibraryItem } from './types'; import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export const useSetLocation = () => { export const useSetLocation = () => {
const client = useQueryClient(); const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-location'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-location'],
mutationFn: libraryApi.setLocation, mutationFn: libraryApi.setLocation,
@ -36,7 +39,7 @@ export const useSetLocation = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) => client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, location: variables.location } !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)) 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 { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type ILibraryItem } from './types'; import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export const useSetOwner = () => { export const useSetOwner = () => {
const client = useQueryClient(); const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-owner'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'set-owner'],
mutationFn: libraryApi.setOwner, mutationFn: libraryApi.setOwner,
@ -36,7 +39,7 @@ export const useSetOwner = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) => client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, owner: variables.owner } !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)) 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 { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type ILibraryItem, type IUpdateLibraryItemDTO, LibraryItemType } from './types'; import { type ILibraryItem, type IUpdateLibraryItemDTO, LibraryItemType } from './types';
import { useLibraryListKey } from './use-library';
export const useUpdateItem = () => { export const useUpdateItem = () => {
const client = useQueryClient(); const client = useQueryClient();
const libraryKey = useLibraryListKey();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'update-item'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'update-item'],
mutationFn: libraryApi.updateItem, mutationFn: libraryApi.updateItem,
@ -18,7 +21,7 @@ export const useUpdateItem = () => {
data.item_type === LibraryItemType.RSFORM data.item_type === LibraryItemType.RSFORM
? KEYS.composite.rsItem({ itemID: data.id }) ? KEYS.composite.rsItem({ itemID: data.id })
: KEYS.composite.ossItem({ 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)) prev?.map(item => (item.id === data.id ? data : item))
); );
client.setQueryData(itemKey, (prev: IRSFormDTO | IOperationSchemaDTO | undefined) => client.setQueryData(itemKey, (prev: IRSFormDTO | IOperationSchemaDTO | undefined) =>

View File

@ -1,15 +1,18 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { libraryApi } from './api'; import { type RO } from '@/utils/meta';
import { type ILibraryItem } from './types'; import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library';
export function useUpdateTimestamp() { export function useUpdateTimestamp() {
const client = useQueryClient(); const client = useQueryClient();
const libraryKey = useLibraryListKey();
return { return {
updateTimestamp: (target: number) => updateTimestamp: (target: number) =>
client.setQueryData( client.setQueryData(
libraryApi.libraryListKey, // libraryKey, //
(prev: ILibraryItem[] | undefined) => (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === target ? { ...item, time_update: Date() } : item)) 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 { KEYS } from '@/backend/configuration';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type IVersionUpdateDTO } from './types'; import { type IUpdateVersionDTO } from './types';
export const useVersionUpdate = () => { export const useUpdateVersion = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'update-version'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'update-version'],
mutationFn: libraryApi.versionUpdate, mutationFn: libraryApi.updateVersion,
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
client.setQueryData(KEYS.composite.rsItem({ itemID: variables.itemID }), (prev: IRSFormDTO | undefined) => client.setQueryData(KEYS.composite.rsItem({ itemID: variables.itemID }), (prev: IRSFormDTO | undefined) =>
!prev !prev
@ -41,6 +41,6 @@ export const useVersionUpdate = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { SearchBar } from '@/components/input';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { APP_COLORS } from '@/styling/colors';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { type ILibraryItem, type LibraryItemType } from '../backend/types'; import { type ILibraryItem, type LibraryItemType } from '../backend/types';
@ -91,7 +90,7 @@ export function PickSchema({
const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [ const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [
{ {
when: (item: ILibraryItem) => item.id === value, 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', !dense && 'h-7 sm:h-8',
'pr-3 py-1 flex items-center gap-2', 'pr-3 py-1 flex items-center gap-2',
'cc-scroll-row', 'cc-scroll-row',
'cc-hover cc-animate-color', 'cc-hover cc-animate-color duration-fade',
'cursor-pointer', 'cursor-pointer',
'leading-3 sm:leading-4', 'leading-3 sm:leading-4',
activeNode === item && 'cc-selected' activeNode === item && 'cc-selected'

View File

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

View File

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

View File

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

View File

@ -99,7 +99,7 @@ export function FormCreateItem() {
return ( return (
<form <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)} onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors} onChange={resetErrors}
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,24 +5,25 @@
import { type ILibraryItem } from '@/features/library'; import { type ILibraryItem } from '@/features/library';
import { Graph } from '@/models/graph'; 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'; 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 { export class OssLoader {
private oss: IOperationSchema; private oss: IOperationSchema;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private hierarchy: Graph = new Graph();
private operationByID = new Map<number, IOperation>(); private operationByID = new Map<number, IOperation>();
private blockByID = new Map<number, IBlock>();
private schemaIDs: number[] = []; private schemaIDs: number[] = [];
private items: ILibraryItem[]; private items: RO<ILibraryItem[]>;
constructor(input: IOperationSchemaDTO, items: ILibraryItem[]) { constructor(input: RO<IOperationSchemaDTO>, items: RO<ILibraryItem[]>) {
this.oss = input as unknown as IOperationSchema; this.oss = structuredClone(input) as IOperationSchema;
this.items = items; this.items = items;
} }
@ -32,9 +33,12 @@ export class OssLoader {
this.createGraph(); this.createGraph();
this.extractSchemas(); this.extractSchemas();
this.inferOperationAttributes(); this.inferOperationAttributes();
this.inferBlockAttributes();
result.operationByID = this.operationByID; result.operationByID = this.operationByID;
result.blockByID = this.blockByID;
result.graph = this.graph; result.graph = this.graph;
result.hierarchy = this.hierarchy;
result.schemas = this.schemaIDs; result.schemas = this.schemaIDs;
result.stats = this.calculateStats(); result.stats = this.calculateStats();
return result; return result;
@ -44,6 +48,17 @@ export class OssLoader {
this.oss.operations.forEach(operation => { this.oss.operations.forEach(operation => {
this.operationByID.set(operation.id, operation); this.operationByID.set(operation.id, operation);
this.graph.addNode(operation.id); 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 { private inferConsolidation(operationID: number): boolean {
const inputs = this.graph.expandInputs([operationID]); const inputs = this.graph.expandInputs([operationID]);
if (inputs.length === 0) { if (inputs.length === 0) {
@ -85,13 +110,14 @@ export class OssLoader {
} }
private calculateStats(): IOperationSchemaStats { private calculateStats(): IOperationSchemaStats {
const items = this.oss.operations; const operations = this.oss.operations;
return { return {
count_operations: items.length, count_all: this.oss.operations.length + this.oss.blocks.length,
count_inputs: items.filter(item => item.operation_type === OperationType.INPUT).length, count_inputs: operations.filter(item => item.operation_type === OperationType.INPUT).length,
count_synthesis: items.filter(item => item.operation_type === OperationType.SYNTHESIS).length, count_synthesis: operations.filter(item => item.operation_type === OperationType.SYNTHESIS).length,
count_schemas: this.schemaIDs.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 { z } from 'zod';
import { schemaLibraryItem } from '@/features/library/backend/types'; 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'; import { errorMsg } from '@/utils/labels';
@ -18,7 +18,7 @@ export type ICstSubstituteInfo = z.infer<typeof schemaCstSubstituteInfo>;
/** Represents {@link IOperation} data from server. */ /** Represents {@link IOperation} data from server. */
export type IOperationDTO = z.infer<typeof schemaOperation>; 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>; export type IBlockDTO = z.infer<typeof schemaBlock>;
/** Represents backend data for {@link IOperationSchema}. */ /** Represents backend data for {@link IOperationSchema}. */
@ -27,33 +27,47 @@ export type IOperationSchemaDTO = z.infer<typeof schemaOperationSchema>;
/** Represents {@link IOperationSchema} layout. */ /** Represents {@link IOperationSchema} layout. */
export type IOssLayout = z.infer<typeof schemaOssLayout>; export type IOssLayout = z.infer<typeof schemaOssLayout>;
/** Represents {@link IOperation} data, used in creation process. */ /** Represents {@link IBlock} data, used in Create action. */
export type IOperationCreateDTO = z.infer<typeof schemaOperationCreate>; 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}. */ /** Represents data response when creating {@link IOperation}. */
export type IOperationCreatedResponse = z.infer<typeof schemaOperationCreatedResponse>; 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 { export interface ITargetOperation {
layout: IOssLayout; layout: IOssLayout;
target: number; 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}. */ /** Represents data response when creating {@link IRSForm} for Input {@link IOperation}. */
export type IInputCreatedResponse = z.infer<typeof schemaInputCreatedResponse>; export type IInputCreatedResponse = z.infer<typeof schemaInputCreatedResponse>;
/** Represents {@link IOperation} data, used in setInput process. */ /** Represents {@link IOperation} data, used in setInput action. */
export type IInputUpdateDTO = z.infer<typeof schemaInputUpdate>; export type IUpdateInputDTO = z.infer<typeof schemaUpdateInput>;
/** Represents {@link IOperation} data, used in update process. */
export type IOperationUpdateDTO = z.infer<typeof schemaOperationUpdate>;
/** Represents data, used relocating {@link IConstituenta}s between {@link ILibraryItem}s. */ /** 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. */ /** Represents {@link IConstituenta} reference. */
export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>; export type IConstituentaReference = z.infer<typeof schemaConstituentaReference>;
@ -80,7 +94,7 @@ export const schemaBlock = z.strictObject({
parent: z.number().nullable() parent: z.number().nullable()
}); });
export const schemaCstSubstituteInfo = schemaCstSubstitute.extend({ export const schemaCstSubstituteInfo = schemaSubstituteConstituents.extend({
operation: z.number(), operation: z.number(),
original_alias: z.string(), original_alias: z.string(),
original_term: z.string(), original_term: z.string(),
@ -121,7 +135,42 @@ export const schemaOperationSchema = schemaLibraryItem.extend({
substitutions: z.array(schemaCstSubstituteInfo) 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, layout: schemaOssLayout,
item_data: z.strictObject({ item_data: z.strictObject({
alias: z.string().nonempty(), alias: z.string().nonempty(),
@ -142,14 +191,34 @@ export const schemaOperationCreatedResponse = z.strictObject({
oss: schemaOperationSchema 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(), target: z.number(),
layout: schemaOssLayout, layout: schemaOssLayout,
keep_constituents: z.boolean(), keep_constituents: z.boolean(),
delete_schema: 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(), target: z.number(),
layout: schemaOssLayout, layout: schemaOssLayout,
input: z.number().nullable() input: z.number().nullable()
@ -160,19 +229,7 @@ export const schemaInputCreatedResponse = z.strictObject({
oss: schemaOperationSchema oss: schemaOperationSchema
}); });
export const schemaOperationUpdate = z.strictObject({ export const schemaRelocateConstituents = 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({
destination: z.number().nullable(), destination: z.number().nullable(),
items: z.array(z.number()).refine(data => data.length > 0) 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 { KEYS } from '@/backend/configuration';
import { ossApi } from './api'; import { ossApi } from './api';
import { type IOperationCreateDTO } from './types'; import { type ICreateBlockDTO } from './types';
export const useOperationCreate = () => { export const useCreateBlock = () => {
const client = useQueryClient(); const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp(); const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-create'], mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'create-block'],
mutationFn: ossApi.operationCreate, mutationFn: ossApi.createBlock,
onSuccess: response => { onSuccess: response => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: response.oss.id }).queryKey, response.oss); client.setQueryData(ossApi.getOssQueryOptions({ itemID: response.oss.id }).queryKey, response.oss);
updateTimestamp(response.oss.id); updateTimestamp(response.oss.id);
@ -20,6 +20,6 @@ export const useOperationCreate = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { ossApi } from './api';
import { type ITargetOperation } from './types'; import { type ITargetOperation } from './types';
export const useInputCreate = () => { export const useCreateInput = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'input-create'], mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'create-input'],
mutationFn: ossApi.inputCreate, mutationFn: ossApi.createInput,
onSuccess: data => { onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.oss.id }).queryKey, data.oss); client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.oss.id }).queryKey, data.oss);
return Promise.allSettled([ return Promise.allSettled([
@ -20,7 +20,7 @@ export const useInputCreate = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { return {
inputCreate: (data: { itemID: number; data: ITargetOperation }) => createInput: (data: { itemID: number; data: ITargetOperation }) =>
mutation.mutateAsync(data).then(response => response.new_schema) 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 { KEYS } from '@/backend/configuration';
import { ossApi } from './api'; import { ossApi } from './api';
import { type IInputUpdateDTO } from './types'; import { type IDeleteBlockDTO } from './types';
export const useInputUpdate = () => { export const useDeleteBlock = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'input-update'], mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'delete-block'],
mutationFn: ossApi.inputUpdate, mutationFn: ossApi.deleteBlock,
onSuccess: data => { onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data); client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([ return Promise.allSettled([
@ -20,6 +20,6 @@ export const useInputUpdate = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { KEYS } from '@/backend/configuration';
import { ossApi } from './api'; import { ossApi } from './api';
import { type IOperationDeleteDTO } from './types'; import { type IDeleteOperationDTO } from './types';
export const useOperationDelete = () => { export const useDeleteOperation = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-delete'], mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'delete-operation'],
mutationFn: ossApi.operationDelete, mutationFn: ossApi.deleteOperation,
onSuccess: data => { onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data); client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([ return Promise.allSettled([
@ -20,6 +20,6 @@ export const useOperationDelete = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { ossApi } from './api';
import { type ITargetOperation } from './types'; import { type ITargetOperation } from './types';
export const useOperationExecute = () => { export const useExecuteOperation = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-execute'], mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'execute-operation'],
mutationFn: ossApi.operationExecute, mutationFn: ossApi.executeOperation,
onSuccess: data => { onSuccess: data => {
client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data); client.setQueryData(ossApi.getOssQueryOptions({ itemID: data.id }).queryKey, data);
return Promise.allSettled([ return Promise.allSettled([
@ -20,6 +20,6 @@ export const useOperationExecute = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { KEYS } from '@/backend/configuration';
import { ossApi } from './api'; import { ossApi } from './api';
import { type ICstRelocateDTO } from './types'; import { type IRelocateConstituentsDTO } from './types';
export const useRelocateConstituents = () => { export const useRelocateConstituents = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -20,6 +20,6 @@ export const useRelocateConstituents = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { KEYS } from '@/backend/configuration';
import { ossApi } from './api'; import { ossApi } from './api';
import { type IOperationUpdateDTO } from './types'; import { type IUpdateBlockDTO } from './types';
export const useOperationUpdate = () => { export const useUpdateBlock = () => {
const client = useQueryClient(); const client = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'operation-update'], mutationKey: [KEYS.global_mutation, ossApi.baseKey, 'update-block'],
mutationFn: ossApi.operationUpdate, mutationFn: ossApi.updateBlock,
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
client.setQueryData(KEYS.composite.ossItem({ itemID: data.id }), data); client.setQueryData(KEYS.composite.ossItem({ itemID: data.id }), data);
const schemaID = data.operations.find(item => item.id === variables.data.target)?.result; const schemaID = data.operations.find(item => item.id === variables.data.target)?.result;
@ -32,6 +32,6 @@ export const useOperationUpdate = () => {
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { 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 { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { ossApi } from './api'; import { ossApi } from './api';
import { type IOperationSchemaDTO, type IOssLayout } from './types'; import { type IOperationSchemaDTO, type IOssLayout } from './types';
@ -17,7 +18,7 @@ export const useUpdateLayout = () => {
updateTimestamp(variables.itemID); updateTimestamp(variables.itemID);
client.setQueryData( client.setQueryData(
ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey, ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey,
(prev: IOperationSchemaDTO | undefined) => (prev: RO<IOperationSchemaDTO> | undefined) =>
!prev !prev
? 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