Compare commits

..

10 Commits

Author SHA1 Message Date
Ivan
513d6a5b71 R: Optimize database queries
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2024-08-06 23:13:57 +03:00
Ivan
91642816b1 F: Implement editors change for OSS -> RSForm 2024-08-06 22:35:32 +03:00
Ivan
edc728fe00 R: Upgrade to eslint9 2024-08-06 14:38:10 +03:00
Ivan
be0dfdefd8 npm update 2024-08-06 00:05:01 +03:00
Ivan
92a0453b18 F: Implement RSForm and OSS attribute sync 2024-08-05 23:53:07 +03:00
Ivan
c0d01957ff M: Update icons 2024-08-03 11:59:51 +03:00
Ivan
cc8cb2d53c M: Update manuals 2024-08-03 11:30:47 +03:00
Ivan
95d38cea7c R: Test Inheritance and update initial data 2024-08-02 15:15:53 +03:00
Ivan
e96206b7db M: Improve node positioning in OSS 2024-08-02 11:17:27 +03:00
Ivan
044a484607 M: Add Operation to Inheritance model 2024-08-02 11:16:41 +03:00
83 changed files with 11746 additions and 6311 deletions

View File

@ -28,7 +28,9 @@
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
"source.organizeImports": "explicit",
"source.fixAll.ts": "never",
"source.fixAll.eslint": "never"
}
},
"python.testing.unittestArgs": ["-v", "-s", "./tests", "-p", "test*.py"],

View File

@ -0,0 +1,21 @@
# Generated by Django 5.0.7 on 2024-08-06 19:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='editor',
name='editor',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Редактор'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.7 on 2024-08-06 19:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0002_alter_editor_editor'),
]
operations = [
migrations.AlterField(
model_name='librarytemplate',
name='lib_source',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.libraryitem', verbose_name='Источник'),
),
]

View File

@ -1,14 +1,11 @@
''' Models: Editor. '''
from typing import TYPE_CHECKING
from typing import Iterable
from django.db import transaction
from django.db.models import CASCADE, DateTimeField, ForeignKey, Model
from apps.users.models import User
if TYPE_CHECKING:
from .LibraryItem import LibraryItem
class Editor(Model):
''' Editor list. '''
@ -20,8 +17,7 @@ class Editor(Model):
editor: ForeignKey = ForeignKey(
verbose_name='Редактор',
to=User,
on_delete=CASCADE,
null=True
on_delete=CASCADE
)
time_create: DateTimeField = DateTimeField(
verbose_name='Дата добавления',
@ -38,17 +34,17 @@ class Editor(Model):
return f'{self.item}: {self.editor}'
@staticmethod
def add(item: 'LibraryItem', user: User) -> bool:
def add(item: int, user: int) -> bool:
''' Add Editor for item. '''
if Editor.objects.filter(item=item, editor=user).exists():
if Editor.objects.filter(item_id=item, editor_id=user).exists():
return False
Editor.objects.create(item=item, editor=user)
Editor.objects.create(item_id=item, editor_id=user)
return True
@staticmethod
def remove(item: 'LibraryItem', user: User) -> bool:
def remove(item: int, user: int) -> bool:
''' Remove Editor. '''
editor = Editor.objects.filter(item=item, editor=user)
editor = Editor.objects.filter(item_id=item, editor_id=user).only('pk')
if not editor.exists():
return False
editor.delete()
@ -56,16 +52,40 @@ class Editor(Model):
@staticmethod
@transaction.atomic
def set(item: 'LibraryItem', users: list[User]):
def set(item: int, users: Iterable[int]):
''' Set editors for item. '''
processed: list[User] = []
for editor_item in Editor.objects.filter(item=item):
if editor_item.editor not in users:
processed: set[int] = set()
for editor_item in Editor.objects.filter(item_id=item).only('editor_id'):
editor_id = editor_item.editor_id
if editor_id not in users:
editor_item.delete()
else:
processed.append(editor_item.editor)
processed.add(editor_id)
for user in users:
if user not in processed:
processed.add(user)
Editor.objects.create(item_id=item, editor_id=user)
@staticmethod
@transaction.atomic
def set_and_return_diff(item: int, users: Iterable[int]) -> tuple[list[int], list[int]]:
''' Set editors for item and return diff. '''
processed: list[int] = []
deleted: list[int] = []
added: list[int] = []
for editor_item in Editor.objects.filter(item_id=item).only('editor_id'):
editor_id = editor_item.editor_id
if editor_id not in users:
deleted.append(editor_id)
editor_item.delete()
else:
processed.append(editor_id)
for user in users:
if user not in processed:
processed.append(user)
Editor.objects.create(item=item, editor=user)
added.append(user)
Editor.objects.create(item_id=item, editor_id=user)
return (added, deleted)

View File

@ -16,7 +16,6 @@ from django.db.models import (
from apps.users.models import User
from .Editor import Editor
from .Subscription import Subscription
from .Version import Version
@ -115,18 +114,19 @@ class LibraryItem(Model):
def get_absolute_url(self):
return f'/api/library/{self.pk}'
def subscribers(self) -> list[User]:
def subscribers(self) -> QuerySet[User]:
''' Get all subscribers for this item. '''
return [subscription.user for subscription in Subscription.objects.filter(item=self.pk).only('user')]
return User.objects.filter(subscription__item=self.pk)
def editors(self) -> list[User]:
def editors(self) -> QuerySet[User]:
''' Get all Editors of this item. '''
return [item.editor for item in Editor.objects.filter(item=self.pk).only('editor')]
return User.objects.filter(editor__item=self.pk)
def versions(self) -> QuerySet[Version]:
''' Get all Versions of this item. '''
return Version.objects.filter(item=self.pk).order_by('-time_create')
# TODO: move to View layer
@transaction.atomic
def save(self, *args, **kwargs):
''' Save updating subscriptions and connected operations. '''
@ -135,7 +135,7 @@ class LibraryItem(Model):
subscribe = self._state.adding and self.owner
super().save(*args, **kwargs)
if subscribe:
Subscription.subscribe(user=self.owner, item=self)
Subscription.subscribe(user=self.owner_id, item=self.pk)
def _update_connected_operations(self):
# using method level import to prevent circular dependency

View File

@ -7,8 +7,7 @@ class LibraryTemplate(Model):
lib_source: ForeignKey = ForeignKey(
verbose_name='Источник',
to='library.LibraryItem',
on_delete=CASCADE,
null=True
on_delete=CASCADE
)
class Meta:

View File

@ -1,13 +1,8 @@
''' Models: Subscription. '''
from typing import TYPE_CHECKING
from django.db.models import CASCADE, ForeignKey, Model
from apps.users.models import User
if TYPE_CHECKING:
from .LibraryItem import LibraryItem
class Subscription(Model):
''' User subscription to library item. '''
@ -32,17 +27,17 @@ class Subscription(Model):
return f'{self.user} -> {self.item}'
@staticmethod
def subscribe(user: User, item: 'LibraryItem') -> bool:
def subscribe(user: int, item: int) -> bool:
''' Add subscription. '''
if Subscription.objects.filter(user=user, item=item).exists():
if Subscription.objects.filter(user_id=user, item_id=item).exists():
return False
Subscription.objects.create(user=user, item=item)
Subscription.objects.create(user_id=user, item_id=item)
return True
@staticmethod
def unsubscribe(user: User, item: 'LibraryItem') -> bool:
def unsubscribe(user: int, item: int) -> bool:
''' Remove subscription. '''
sub = Subscription.objects.filter(user=user, item=item)
sub = Subscription.objects.filter(user_id=user, item_id=item).only('pk')
if not sub.exists():
return False
sub.delete()

View File

@ -83,10 +83,10 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
read_only_fields = ('owner', 'id', 'item_type')
def get_subscribers(self, instance: LibraryItem) -> list[int]:
return [item.pk for item in instance.subscribers()]
return list(instance.subscribers().values_list('pk', flat=True))
def get_editors(self, instance: LibraryItem) -> list[int]:
return [item.pk for item in instance.editors()]
return list(instance.editors().values_list('pk', flat=True))
def get_versions(self, instance: LibraryItem) -> list:
return [VersionInnerSerializer(item).data for item in instance.versions()]
@ -94,9 +94,9 @@ class LibraryItemDetailsSerializer(serializers.ModelSerializer):
class UserTargetSerializer(serializers.Serializer):
''' Serializer: Target single User. '''
user = PKField(many=False, queryset=User.objects.all())
user = PKField(many=False, queryset=User.objects.all().only('pk'))
class UsersListSerializer(serializers.Serializer):
''' Serializer: List of Users. '''
users = PKField(many=True, queryset=User.objects.all())
users = PKField(many=True, queryset=User.objects.all().only('pk'))

View File

@ -34,44 +34,65 @@ class TestEditor(TestCase):
def test_add_editor(self):
self.assertTrue(Editor.add(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(self.user1 in self.item.editors())
self.assertTrue(Editor.add(self.item.pk, self.user1.pk))
self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(self.user1 in list(self.item.editors()))
self.assertFalse(Editor.add(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertFalse(Editor.add(self.item.pk, self.user1.pk))
self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(Editor.add(self.item, self.user2))
self.assertEqual(len(self.item.editors()), 2)
self.assertTrue(Editor.add(self.item.pk, self.user2.pk))
self.assertEqual(self.item.editors().count(), 2)
self.assertTrue(self.user1 in self.item.editors())
self.assertTrue(self.user2 in self.item.editors())
self.user1.delete()
self.assertEqual(len(self.item.editors()), 1)
self.assertEqual(self.item.editors().count(), 1)
def test_remove_editor(self):
self.assertFalse(Editor.remove(self.item, self.user1))
Editor.add(self.item, self.user1)
Editor.add(self.item, self.user2)
self.assertEqual(len(self.item.editors()), 2)
self.assertFalse(Editor.remove(self.item.pk, self.user1.pk))
Editor.add(self.item.pk, self.user1.pk)
Editor.add(self.item.pk, self.user2.pk)
self.assertEqual(self.item.editors().count(), 2)
self.assertTrue(Editor.remove(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(Editor.remove(self.item.pk, self.user1.pk))
self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(self.user2 in self.item.editors())
self.assertFalse(Editor.remove(self.item, self.user1))
self.assertFalse(Editor.remove(self.item.pk, self.user1.pk))
def test_set_editors(self):
Editor.set(self.item, [self.user1])
self.assertEqual(self.item.editors(), [self.user1])
Editor.set(self.item.pk, [self.user1.pk])
self.assertEqual(list(self.item.editors()), [self.user1])
Editor.set(self.item, [self.user1, self.user1])
self.assertEqual(self.item.editors(), [self.user1])
Editor.set(self.item.pk, [self.user1.pk, self.user1.pk])
self.assertEqual(list(self.item.editors()), [self.user1])
Editor.set(self.item, [])
self.assertEqual(self.item.editors(), [])
Editor.set(self.item.pk, [])
self.assertEqual(list(self.item.editors()), [])
Editor.set(self.item, [self.user1, self.user2])
Editor.set(self.item.pk, [self.user1.pk, self.user2.pk])
self.assertEqual(set(self.item.editors()), set([self.user1, self.user2]))
def test_set_editors_return_diff(self):
added, deleted = Editor.set_and_return_diff(self.item.pk, [self.user1.pk])
self.assertEqual(added, [self.user1.pk])
self.assertEqual(deleted, [])
self.assertEqual(list(self.item.editors()), [self.user1])
added, deleted = Editor.set_and_return_diff(self.item.pk, [self.user1.pk, self.user1.pk])
self.assertEqual(added, [])
self.assertEqual(deleted, [])
self.assertEqual(list(self.item.editors()), [self.user1])
added, deleted = Editor.set_and_return_diff(self.item.pk, [])
self.assertEqual(added, [])
self.assertEqual(deleted, [self.user1.pk])
self.assertEqual(list(self.item.editors()), [])
added, deleted = Editor.set_and_return_diff(self.item.pk, [self.user1.pk, self.user2.pk])
self.assertEqual(added, [self.user1.pk, self.user2.pk])
self.assertEqual(deleted, [])
self.assertEqual(set(self.item.editors()), set([self.user1, self.user2]))

View File

@ -37,33 +37,33 @@ class TestSubscription(TestCase):
def test_subscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertEqual(len(item.subscribers()), 0)
self.assertEqual(item.subscribers().count(), 0)
self.assertTrue(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(Subscription.subscribe(self.user1.pk, item.pk))
self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(self.user1 in item.subscribers())
self.assertFalse(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertFalse(Subscription.subscribe(self.user1.pk, item.pk))
self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(Subscription.subscribe(self.user2, item))
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(Subscription.subscribe(self.user2.pk, item.pk))
self.assertEqual(item.subscribers().count(), 2)
self.assertTrue(self.user1 in item.subscribers())
self.assertTrue(self.user2 in item.subscribers())
self.user1.delete()
self.assertEqual(len(item.subscribers()), 1)
self.assertEqual(item.subscribers().count(), 1)
def test_unsubscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
self.assertFalse(Subscription.unsubscribe(self.user1, item))
Subscription.subscribe(self.user1, item)
Subscription.subscribe(self.user2, item)
self.assertEqual(len(item.subscribers()), 2)
self.assertFalse(Subscription.unsubscribe(self.user1.pk, item.pk))
Subscription.subscribe(self.user1.pk, item.pk)
Subscription.subscribe(self.user2.pk, item.pk)
self.assertEqual(item.subscribers().count(), 2)
self.assertTrue(Subscription.unsubscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(Subscription.unsubscribe(self.user1.pk, item.pk))
self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(self.user2 in item.subscribers())
self.assertFalse(Subscription.unsubscribe(self.user1, item))
self.assertFalse(Subscription.unsubscribe(self.user1.pk, item.pk))

View File

@ -183,57 +183,6 @@ class TestLibraryViewset(EndpointTester):
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location'])
@decl_endpoint('/api/library/{item}/add-editor', method='patch')
def test_add_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data=data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user])
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
data = {'user': self.user2.pk}
self.executeOK(data=data)
self.assertEqual(set(self.owned.editors()), set([self.user, self.user2]))
@decl_endpoint('/api/library/{item}/remove-editor', method='patch')
def test_remove_editor(self):
time_update = self.owned.time_update
data = {'user': self.invalid_user}
self.executeBadData(data=data, item=self.owned.pk)
data = {'user': self.user.pk}
self.executeNotFound(data=data, item=self.invalid_item)
self.executeForbidden(data=data, item=self.unowned.pk)
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [])
Editor.add(item=self.owned, user=self.user)
Editor.add(item=self.owned, user=self.user2)
data = {'user': self.user2.pk}
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
@decl_endpoint('/api/library/{item}/set-editors', method='patch')
def test_set_editors(self):
time_update = self.owned.time_update
@ -248,18 +197,18 @@ class TestLibraryViewset(EndpointTester):
self.executeOK(data=data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.time_update, time_update)
self.assertEqual(self.owned.editors(), [self.user])
self.assertEqual(list(self.owned.editors()), [self.user])
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user])
self.assertEqual(list(self.owned.editors()), [self.user])
data = {'users': [self.user2.pk]}
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [self.user2])
self.assertEqual(list(self.owned.editors()), [self.user2])
data = {'users': []}
self.executeOK(data=data)
self.assertEqual(self.owned.editors(), [])
self.assertEqual(list(self.owned.editors()), [])
data = {'users': [self.user2.pk, self.user.pk]}
self.executeOK(data=data)
@ -320,9 +269,9 @@ class TestLibraryViewset(EndpointTester):
response = self.executeOK()
self.assertFalse(response_contains(response, self.unowned))
Subscription.subscribe(user=self.user, item=self.unowned)
Subscription.subscribe(user=self.user2, item=self.unowned)
Subscription.subscribe(user=self.user2, item=self.owned)
Subscription.subscribe(user=self.user.pk, item=self.unowned.pk)
Subscription.subscribe(user=self.user2.pk, item=self.unowned.pk)
Subscription.subscribe(user=self.user2.pk, item=self.owned.pk)
response = self.executeOK()
self.assertTrue(response_contains(response, self.unowned))

View File

@ -4,15 +4,15 @@ from typing import cast
from django.db import transaction
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import filters, generics
from rest_framework import generics
from rest_framework import status as c
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from apps.oss.models import OperationSchema
from apps.rsform.models import RSForm
from apps.rsform.serializers import RSFormParseSerializer
from apps.users.models import User
@ -27,10 +27,8 @@ from .. import serializers as s
class LibraryViewSet(viewsets.ModelViewSet):
''' Endpoint: Library operations. '''
queryset = m.LibraryItem.objects.all()
# TODO: consider using .only() for performance
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_fields = ['item_type', 'owner']
ordering_fields = ('item_type', 'owner', 'alias', 'title', 'time_update')
ordering = '-time_update'
def get_serializer_class(self):
@ -52,8 +50,6 @@ class LibraryViewSet(viewsets.ModelViewSet):
'set_owner',
'set_access_policy',
'set_location',
'add_editor',
'remove_editor',
'set_editors'
]:
access_level = permissions.ItemOwner
@ -129,7 +125,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def subscribe(self, request: Request, pk):
''' Endpoint: Subscribe current user to item. '''
item = self._get_item()
m.Subscription.subscribe(user=cast(User, self.request.user), item=item)
m.Subscription.subscribe(user=cast(int, self.request.user.pk), item=item.pk)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -146,7 +142,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
def unsubscribe(self, request: Request, pk):
''' Endpoint: Unsubscribe current user from item. '''
item = self._get_item()
m.Subscription.unsubscribe(user=cast(User, self.request.user), item=item)
m.Subscription.unsubscribe(user=cast(int, self.request.user.pk), item=item.pk)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -165,29 +161,19 @@ class LibraryViewSet(viewsets.ModelViewSet):
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_owner = serializer.validated_data['user']
m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner)
return Response(status=c.HTTP_200_OK)
new_owner = serializer.validated_data['user'].pk
if new_owner == item.owner_id:
return Response(status=c.HTTP_200_OK)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('owner')
for schema in owned_schemas:
schema.owner_id = new_owner
m.LibraryItem.objects.bulk_update(owned_schemas, ['owner'])
item.owner_id = new_owner
item.save(update_fields=['owner'])
@extend_schema(
summary='set AccessPolicy for item',
tags=['Library'],
request=s.AccessPolicySerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='set-access-policy')
def set_access_policy(self, request: Request, pk):
''' Endpoint: Set item AccessPolicy. '''
item = self._get_item()
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_policy = serializer.validated_data['access_policy']
m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=new_policy)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -208,49 +194,52 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer = s.LocationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
location: str = serializer.validated_data['location']
if location == item.location:
return Response(status=c.HTTP_200_OK)
if location.startswith(m.LocationHead.LIBRARY) and not self.request.user.is_staff:
return Response(status=c.HTTP_403_FORBIDDEN)
m.LibraryItem.objects.filter(pk=item.pk).update(location=location)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('location')
for schema in owned_schemas:
schema.location = location
m.LibraryItem.objects.bulk_update(owned_schemas, ['location'])
item.location = location
item.save(update_fields=['location'])
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='add editor for item',
summary='set AccessPolicy for item',
tags=['Library'],
request=s.UserTargetSerializer,
request=s.AccessPolicySerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='add-editor')
def add_editor(self, request: Request, pk):
''' Endpoint: Add editor for item. '''
@action(detail=True, methods=['patch'], url_path='set-access-policy')
def set_access_policy(self, request: Request, pk):
''' Endpoint: Set item AccessPolicy. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_editor = serializer.validated_data['user']
m.Editor.add(item=item, user=new_editor)
return Response(status=c.HTTP_200_OK)
new_policy = serializer.validated_data['access_policy']
if new_policy == item.access_policy:
return Response(status=c.HTTP_200_OK)
with transaction.atomic():
if item.item_type == m.LibraryItemType.OPERATION_SCHEMA:
owned_schemas = OperationSchema(item).owned_schemas().only('access_policy')
for schema in owned_schemas:
schema.access_policy = new_policy
m.LibraryItem.objects.bulk_update(owned_schemas, ['access_policy'])
item.access_policy = new_policy
item.save(update_fields=['access_policy'])
@extend_schema(
summary='remove editor for item',
tags=['Library'],
request=s.UserTargetSerializer,
responses={
c.HTTP_200_OK: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='remove-editor')
def remove_editor(self, request: Request, pk):
''' Endpoint: Remove editor for item. '''
item = self._get_item()
serializer = s.UserTargetSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
editor = serializer.validated_data['user']
m.Editor.remove(item=item, user=editor)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -269,8 +258,32 @@ class LibraryViewSet(viewsets.ModelViewSet):
item = self._get_item()
serializer = s.UsersListSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
editors = serializer.validated_data['users']
m.Editor.set(item=item, users=editors)
editors: list[int] = request.data['users']
with transaction.atomic():
added, deleted = m.Editor.set_and_return_diff(item.pk, editors)
if len(added) >= 0 or len(deleted) >= 0:
owned_schemas = OperationSchema(item).owned_schemas().only('pk')
if owned_schemas.exists():
m.Editor.objects.filter(
item__in=owned_schemas,
editor_id__in=deleted
).delete()
existing_editors = m.Editor.objects.filter(
item__in=owned_schemas,
editor__in=added
).values_list('item_id', 'editor_id')
existing_editor_set = set(existing_editors)
new_editors = [
m.Editor(item=schema, editor_id=user)
for schema in owned_schemas
for user in added
if (item.id, user) not in existing_editor_set
]
m.Editor.objects.bulk_create(new_editors)
return Response(status=c.HTTP_200_OK)

View File

@ -25,6 +25,14 @@ class SynthesisSubstitutionAdmin(admin.ModelAdmin):
search_fields = ['id', 'operation', 'original', 'substitution']
class InheritanceAdmin(admin.ModelAdmin):
''' Admin model: Inheritance. '''
ordering = ['operation']
list_display = ['id', 'operation', 'parent', 'child']
search_fields = ['id', 'operation', 'parent', 'child']
admin.site.register(models.Operation, OperationAdmin)
admin.site.register(models.Argument, ArgumentAdmin)
admin.site.register(models.Substitution, SynthesisSubstitutionAdmin)
admin.site.register(models.Inheritance, InheritanceAdmin)

View File

@ -0,0 +1,20 @@
# Generated by Django 5.0.7 on 2024-08-02 07:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0004_remove_substitution_transfer_term'),
]
operations = [
migrations.AddField(
model_name='inheritance',
name='operation',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='inheritances', to='oss.operation', verbose_name='Операция'),
preserve_default=False,
),
]

View File

@ -4,6 +4,12 @@ from django.db.models import CASCADE, ForeignKey, Model
class Inheritance(Model):
''' Inheritance links parent and child constituents in synthesis operation.'''
operation: ForeignKey = ForeignKey(
verbose_name='Операция',
to='oss.Operation',
on_delete=CASCADE,
related_name='inheritances'
)
parent: ForeignKey = ForeignKey(
verbose_name='Исходная конституента',
to='rsform.Constituenta',

View File

@ -51,6 +51,14 @@ class OperationSchema:
''' Operation substitutions. '''
return Substitution.objects.filter(operation__oss=self.model)
def owned_schemas(self) -> QuerySet[LibraryItem]:
''' Get QuerySet containing all result schemas owned by current OSS. '''
return LibraryItem.objects.filter(
producer__oss=self.model,
owner_id=self.model.owner_id,
location=self.model.location
)
def update_positions(self, data: list[dict]):
''' Update positions. '''
lookup = {x['id']: x for x in data}
@ -161,7 +169,7 @@ class OperationSchema:
access_policy=self.model.access_policy,
location=self.model.location
)
Editor.set(schema.model, self.model.editors())
Editor.set(schema.model.pk, self.model.editors().values_list('pk', flat=True))
operation.result = schema.model
operation.save()
self.save()
@ -197,6 +205,7 @@ class OperationSchema:
parent = parents.get(cst.pk)
assert parent is not None
Inheritance.objects.create(
operation=operation,
child=cst,
parent=parent
)

View File

@ -48,7 +48,7 @@ class OperationCreateSerializer(serializers.Serializer):
create_schema = serializers.BooleanField(default=False, required=False)
item_data = OperationCreateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
arguments = PKField(many=True, queryset=Operation.objects.all().only('pk'), required=False)
positions = serializers.ListField(
child=OperationPositionSerializer(),
@ -67,7 +67,7 @@ class OperationUpdateSerializer(serializers.Serializer):
target = PKField(many=False, queryset=Operation.objects.all())
item_data = OperationUpdateData()
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
arguments = PKField(many=True, queryset=Operation.objects.all().only('oss_id', 'result_id'), required=False)
substitutions = serializers.ListField(
child=SubstitutionSerializerBase(),
required=False
@ -121,8 +121,8 @@ class OperationUpdateSerializer(serializers.Serializer):
class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Delete operation. '''
target = PKField(many=False, queryset=Operation.objects.all())
''' Serializer: Target single operation. '''
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]

View File

@ -1,4 +1,5 @@
''' Tests for Django Models. '''
from .t_Argument import *
from .t_Inheritance import *
from .t_Operation import *
from .t_Substitution import *

View File

@ -0,0 +1,51 @@
''' Testing models: Inheritance. '''
from django.test import TestCase
from apps.oss.models import Inheritance, Operation, OperationSchema, OperationType
from apps.rsform.models import Constituenta, RSForm
class TestInheritance(TestCase):
''' Testing Inheritance model. '''
def setUp(self):
self.oss = OperationSchema.create(alias='T1')
self.ks1 = RSForm.create(title='Test1', alias='KS1')
self.ks2 = RSForm.create(title='Test2', alias='KS2')
self.operation = Operation.objects.create(
oss=self.oss.model,
alias='KS1',
operation_type=OperationType.INPUT,
result=self.ks1.model
)
self.ks1_x1 = self.ks1.insert_new('X1')
self.ks2_x1 = self.ks2.insert_new('X1')
self.inheritance = Inheritance.objects.create(
operation=self.operation,
parent=self.ks1_x1,
child=self.ks2_x1
)
def test_str(self):
testStr = f'{self.ks1_x1} -> {self.ks2_x1}'
self.assertEqual(str(self.inheritance), testStr)
def test_cascade_delete_operation(self):
self.assertEqual(Inheritance.objects.count(), 1)
self.operation.delete()
self.assertEqual(Inheritance.objects.count(), 0)
def test_cascade_delete_parent(self):
self.assertEqual(Inheritance.objects.count(), 1)
self.ks1_x1.delete()
self.assertEqual(Inheritance.objects.count(), 0)
def test_cascade_delete_child(self):
self.assertEqual(Inheritance.objects.count(), 1)
self.ks2_x1.delete()
self.assertEqual(Inheritance.objects.count(), 0)

View File

@ -1,2 +1,3 @@
''' Tests for REST API. '''
from .t_change_attributes import *
from .t_oss import *

View File

@ -0,0 +1,125 @@
''' Testing API: Change attributes of OSS and RSForms. '''
from rest_framework import status
from apps.library.models import AccessPolicy, Editor, LocationHead
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm
from apps.users.models import User
from shared.EndpointTester import EndpointTester, decl_endpoint
class TestChangeAttributes(EndpointTester):
''' Testing LibraryItem view when OSS is associated with RSForms. '''
def setUp(self):
super().setUp()
self.user3 = User.objects.create(
username='UserTest3',
email='anotheranother@test.com',
password='password'
)
self.owned = OperationSchema.create(
title='Test',
alias='T1',
owner=self.user,
location=LocationHead.LIBRARY
)
self.owned_id = self.owned.model.pk
self.ks1 = RSForm.create(
alias='KS1',
title='Test1',
owner=self.user,
location=LocationHead.USER
)
self.ks2 = RSForm.create(
alias='KS2',
title='Test2',
owner=self.user2,
location=LocationHead.LIBRARY
)
self.operation1 = self.owned.create_operation(
alias='1',
operation_type=OperationType.INPUT,
result=self.ks1.model
)
self.operation2 = self.owned.create_operation(
alias='2',
operation_type=OperationType.INPUT,
result=self.ks2.model
)
self.operation3 = self.owned.create_operation(
alias='3',
operation_type=OperationType.SYNTHESIS
)
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = self.operation3.result
@decl_endpoint('/api/library/{item}/set-owner', method='patch')
def test_set_owner(self):
data = {'user': self.user3.pk}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.owner, self.user3)
self.assertEqual(self.ks1.model.owner, self.user)
self.assertEqual(self.ks2.model.owner, self.user2)
self.assertEqual(self.ks3.owner, self.user3)
@decl_endpoint('/api/library/{item}/set-location', method='patch')
def test_set_location(self):
data = {'location': '/U/temp'}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.location, data['location'])
self.assertNotEqual(self.ks1.model.location, data['location'])
self.assertNotEqual(self.ks2.model.location, data['location'])
self.assertEqual(self.ks3.location, data['location'])
@decl_endpoint('/api/library/{item}/set-access-policy', method='patch')
def test_set_access_policy(self):
data = {'access_policy': AccessPolicy.PROTECTED}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(self.owned.model.access_policy, data['access_policy'])
self.assertNotEqual(self.ks1.model.access_policy, data['access_policy'])
self.assertNotEqual(self.ks2.model.access_policy, data['access_policy'])
self.assertEqual(self.ks3.access_policy, data['access_policy'])
@decl_endpoint('/api/library/{item}/set-editors', method='patch')
def test_set_editors(self):
Editor.set(self.owned.model.pk, [self.user2.pk])
Editor.set(self.ks1.model.pk, [self.user2.pk, self.user.pk])
Editor.set(self.ks3.pk, [self.user2.pk, self.user.pk])
data = {'users': [self.user3.pk]}
self.executeOK(data=data, item=self.owned_id)
self.owned.refresh_from_db()
self.ks1.refresh_from_db()
self.ks2.refresh_from_db()
self.ks3.refresh_from_db()
self.assertEqual(list(self.owned.model.editors()), [self.user3])
self.assertEqual(list(self.ks1.model.editors()), [self.user, self.user2])
self.assertEqual(list(self.ks2.model.editors()), [])
self.assertEqual(set(self.ks3.editors()), set([self.user, self.user3]))

View File

@ -220,7 +220,7 @@ class TestOssViewset(EndpointTester):
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation_schema(self):
self.populateData()
Editor.add(self.owned.model, self.user2)
Editor.add(self.owned.model.pk, self.user2.pk)
data = {
'item_data': {
'alias': 'Test4',

View File

@ -269,7 +269,7 @@ class CstRenameSerializer(serializers.Serializer):
class CstListSerializer(serializers.Serializer):
''' Serializer: List of constituents from one origin. '''
items = PKField(many=True, queryset=Constituenta.objects.all())
items = PKField(many=True, queryset=Constituenta.objects.all().only('schema_id'))
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
@ -291,8 +291,8 @@ class CstMoveSerializer(CstListSerializer):
class SubstitutionSerializerBase(serializers.Serializer):
''' Serializer: Basic substitution. '''
original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema'))
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema'))
original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
class CstSubstituteSerializer(serializers.Serializer):
@ -330,8 +330,8 @@ class CstSubstituteSerializer(serializers.Serializer):
class InlineSynthesisSerializer(serializers.Serializer):
''' Serializer: Inline synthesis operation input. '''
receiver = PKField(many=False, queryset=LibraryItem.objects.all())
source = PKField(many=False, queryset=LibraryItem.objects.all()) # type: ignore
receiver = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id'))
source = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id')) # type: ignore
items = PKField(many=True, queryset=Constituenta.objects.all())
substitutions = serializers.ListField(
child=SubstitutionSerializerBase()

File diff suppressed because it is too large Load Diff

View File

@ -250,9 +250,8 @@ LOGGING = {
'root': {
'handlers': ['console'],
'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO')
},
}
}
if len(sys.argv) > 1 and sys.argv[1] == 'test':
logging.disable(logging.CRITICAL)

View File

@ -1,4 +1,7 @@
''' Utils: base tester class for endpoints. '''
import logging
from django.db import connection
from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
@ -40,6 +43,9 @@ class EndpointTester(APITestCase):
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.logger = logging.getLogger('django.db.backends')
self.logger.setLevel(logging.DEBUG)
def setUpFullUsers(self):
self.factory = APIRequestFactory()
self.user = User.objects.create_user(
@ -61,9 +67,9 @@ class EndpointTester(APITestCase):
def toggle_editor(self, item: LibraryItem, value: bool = True):
if value:
Editor.add(item, self.user)
Editor.add(item.pk, self.user.pk)
else:
Editor.remove(item, self.user)
Editor.remove(item.pk, self.user.pk)
def login(self):
self.client.force_authenticate(user=self.user)
@ -71,6 +77,16 @@ class EndpointTester(APITestCase):
def logout(self):
self.client.logout()
def start_db_log(self):
''' Warning! Do not use this second time before calling stop_db_log. '''
''' Warning! Do not forget to enable global logging in settings. '''
logging.disable(logging.NOTSET)
connection.force_debug_cursor = True
def stop_db_log(self):
connection.force_debug_cursor = False
logging.disable(logging.CRITICAL)
def set_params(self, **kwargs):
''' Given named argument values resolve current endpoint_mask. '''
if self.endpoint_mask and len(kwargs) > 0:

View File

@ -1,2 +0,0 @@
**/parser.ts
**/node_modules/**

View File

@ -1,25 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": ["tsconfig.json", "tsconfig.node.json"]
},
"plugins": ["react-refresh", "simple-import-sort", "eslint-plugin-tsdoc"],
"rules": {
"no-console": "off",
"require-jsdoc": "off",
"react-refresh/only-export-components": ["off", { "allowConstantExport": true }],
"simple-import-sort/imports": "warn",
"tsdoc/syntax": "warn"
}
}

View File

@ -10,4 +10,4 @@
"quoteProps": "consistent",
"bracketSameLine": false,
"bracketSpacing": true
}
}

View File

@ -0,0 +1,61 @@
import globals from 'globals';
import typescriptPlugin from 'typescript-eslint';
import typescriptParser from '@typescript-eslint/parser';
import reactPlugin from 'eslint-plugin-react';
// import { fixupPluginRules } from '@eslint/compat';
// import reactHooksPlugin from 'eslint-plugin-react-hooks';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
export default [
...typescriptPlugin.configs.recommendedTypeChecked,
...typescriptPlugin.configs.stylisticTypeChecked,
{
ignores: ['**/parser.ts', '**/node_modules/**', '**/public/**', 'eslint.config.js']
},
{
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser, ...globals.es2020, ...globals.jest },
project: ['./tsconfig.json', './tsconfig.node.json']
}
}
},
{
plugins: {
'react': reactPlugin,
// 'react-hooks': fixupPluginRules(reactHooksPlugin),
'simple-import-sort': simpleImportSort
},
settings: { react: { version: 'detect' } },
rules: {
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
],
'react-refresh/only-export-components': ['off', { allowConstantExport: true }],
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'error'
}
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-console': 'off',
'require-jsdoc': 'off'
}
}
];

File diff suppressed because it is too large Load Diff

View File

@ -8,17 +8,17 @@
"test": "jest",
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.19.3",
"@tanstack/react-table": "^8.20.1",
"@uiw/codemirror-themes": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0",
"axios": "^1.7.2",
"axios": "^1.7.3",
"clsx": "^2.1.1",
"framer-motion": "^11.3.19",
"framer-motion": "^11.3.21",
"html-to-image": "^1.11.11",
"js-file-download": "^0.4.12",
"react": "^18.3.1",
@ -28,11 +28,11 @@
"react-intl": "^6.6.8",
"react-loader-spinner": "^6.1.6",
"react-pdf": "^9.1.0",
"react-router-dom": "^6.25.1",
"react-router-dom": "^6.26.0",
"react-select": "^5.8.0",
"react-tabs": "^6.0.2",
"react-toastify": "^10.0.5",
"react-tooltip": "^5.27.1",
"react-tooltip": "^5.28.0",
"react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4",
"reagraph": "^4.19.2",
@ -41,23 +41,23 @@
"devDependencies": {
"@lezer/generator": "^1.7.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.13",
"@types/node": "^22.1.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.9",
"autoprefixer": "^10.4.20",
"eslint": "^9.8.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-tsdoc": "^0.3.0",
"globals": "^15.9.0",
"jest": "^29.7.0",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"ts-jest": "^29.2.4",
"typescript": "^5.5.4",
"typescript-eslint": "^8.0.1",
"vite": "^5.3.5"
},
"jest": {

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@ -1,6 +1,7 @@
// Search new icons at https://reactsvgicons.com/
// Note: save this file using Ctrl + K, Ctrl + Shift + S to disable autoformat
/* eslint-disable simple-import-sort/exports */
// ==== General actions =======
export { BiMenu as IconMenu } from 'react-icons/bi';
export { LuLogOut as IconLogout } from 'react-icons/lu';
@ -61,11 +62,11 @@ export { TbBriefcase as IconBusiness } from 'react-icons/tb';
export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { GiHoneycomb as IconOSS } from 'react-icons/gi';
export { LuBaby as IconChild } from 'react-icons/lu';
export { TbHexagons as IconOSS } from 'react-icons/tb';
export { TbHexagon as IconRSForm } from 'react-icons/tb';
export { GrInherit as IconChild } from 'react-icons/gr';
export { RiParentLine as IconParent } from 'react-icons/ri';
export { BiSpa as IconPredecessor } from 'react-icons/bi';
export { RiHexagonLine as IconRSForm } from 'react-icons/ri';
export { LuArchive as IconArchive } from 'react-icons/lu';
export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuView as IconDBStructure } from 'react-icons/lu';
@ -99,6 +100,7 @@ export { BiDownvote as IconMoveDown } from 'react-icons/bi';
export { BiRightArrow as IconMoveRight } from 'react-icons/bi';
export { BiLeftArrow as IconMoveLeft } from 'react-icons/bi';
export { FiBell as IconFollow } from 'react-icons/fi';
export { TbHexagonPlus2 as IconNewRSForm } from 'react-icons/tb';
export { FiBellOff as IconFollowOff } from 'react-icons/fi';
export { BiPlusCircle as IconNewItem } from 'react-icons/bi';
export { FaSquarePlus as IconNewItem2 } from 'react-icons/fa6';

View File

@ -14,7 +14,7 @@ function InfoConstituenta({ data, className, ...restProps }: InfoConstituentaPro
return (
<div className={clsx('dense min-w-[15rem]', className)} {...restProps}>
<h2>
Конституента {data.alias}
{data.alias}
{data.is_inherited ? ' (наследуется)' : ''}
</h2>
{data.term_resolved ? (

View File

@ -2,11 +2,11 @@
import { HTMLMotionProps } from 'framer-motion';
export namespace CProps {
export type Titled = {
export interface Titled {
title?: string;
titleHtml?: string;
hideTitle?: boolean;
};
}
export type Control = Titled & {
disabled?: boolean;
@ -14,18 +14,18 @@ export namespace CProps {
noOutline?: boolean;
};
export type Styling = {
export interface Styling {
style?: React.CSSProperties;
className?: string;
};
}
export type Editor = Control & {
label?: string;
};
export type Colors = {
export interface Colors {
colors?: string;
};
}
export type Div = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export type Button = Titled &

View File

@ -17,25 +17,36 @@ interface MiniSelectorOSSProps {
function MiniSelectorOSS({ items, onSelect }: MiniSelectorOSSProps) {
const ossMenu = useDropdown();
function onToggle(event: CProps.EventMouse) {
if (items.length > 1) {
ossMenu.toggle();
} else {
onSelect(event, items[0]);
}
}
return (
<div ref={ossMenu.ref} className='flex items-center'>
<MiniButton
icon={<IconOSS size='1.25rem' className='icon-primary' />}
title='Связанные операционные схемы'
title='Операционные схемы'
hideTitle={ossMenu.isOpen}
onClick={() => ossMenu.toggle()}
onClick={onToggle}
/>
<Dropdown isOpen={ossMenu.isOpen}>
<Label text='Список ОСС' className='border-b px-3 py-1' />
{items.map((reference, index) => (
<DropdownButton
className='min-w-[5rem]'
key={`${prefixes.oss_list}${index}`}
text={reference.alias}
onClick={event => onSelect(event, reference)}
/>
))}
</Dropdown>
{items.length > 1 ? (
<Dropdown isOpen={ossMenu.isOpen}>
<Label text='Список ОСС' className='border-b px-3 py-1' />
{items.map((reference, index) => (
<DropdownButton
className='min-w-[5rem]'
key={`${prefixes.oss_list}${index}`}
text={reference.alias}
onClick={event => onSelect(event, reference)}
/>
))}
</Dropdown>
) : null}
</div>
);
}

View File

@ -140,13 +140,11 @@ function PickSubstitutions({
() => [
columnHelper.accessor(item => item.substitution_source?.alias ?? 'N/A', {
id: 'left_schema',
header: 'Операция',
size: 100,
cell: props => <div className='min-w-[10.5rem] text-ellipsis text-right'>{props.getValue()}</div>
cell: props => <div className='min-w-[10.5rem] text-ellipsis text-left'>{props.getValue()}</div>
}),
columnHelper.accessor(item => item.substitution?.alias ?? 'N/A', {
id: 'left_alias',
header: () => <span className='pl-3'>Имя</span>,
size: 65,
cell: props =>
props.row.original.substitution ? (
@ -157,13 +155,11 @@ function PickSubstitutions({
}),
columnHelper.display({
id: 'status',
header: '',
size: 40,
cell: () => <IconPageRight size='1.2rem' />
}),
columnHelper.accessor(item => item.original?.alias ?? 'N/A', {
id: 'right_alias',
header: () => <span className='pl-3'>Имя</span>,
size: 65,
cell: props =>
props.row.original.original ? (
@ -174,9 +170,8 @@ function PickSubstitutions({
}),
columnHelper.accessor(item => item.original_source?.alias ?? 'N/A', {
id: 'right_schema',
header: 'Операция',
size: 100,
cell: props => <div className='min-w-[8rem] text-ellipsis'>{props.getValue()}</div>
cell: props => <div className='min-w-[8rem] text-ellipsis text-right'>{props.getValue()}</div>
}),
columnHelper.display({
id: 'actions',

View File

@ -25,7 +25,7 @@ function SelectLocation({ value, folderTree, dense, prefix, onClick, className,
const [folded, setFolded] = useState<FolderNode[]>(items);
useLayoutEffect(() => {
setFolded(items.filter(item => item !== activeNode && (!activeNode || !activeNode.hasPredecessor(item))));
setFolded(items.filter(item => item !== activeNode && !activeNode?.hasPredecessor(item)));
}, [items, activeNode]);
const onFoldItem = useCallback(

View File

@ -51,7 +51,7 @@ function SelectUser({
options={options}
value={value ? { value: value, label: getUserLabel(value) } : null}
onChange={data => {
if (data !== null && data.value !== undefined) onSelectValue(data.value);
if (data?.value !== undefined) onSelectValue(data.value);
}}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}

View File

@ -23,7 +23,7 @@ import TableBody from './TableBody';
import TableFooter from './TableFooter';
import TableHeader from './TableHeader';
export { createColumnHelper, type ColumnSort, type RowSelectionState, type VisibilityState };
export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
export interface IConditionalStyle<TData> {
when: (rowData: TData) => boolean;

View File

@ -44,7 +44,7 @@ function TableBody<TData>({
lastIndex > currentIndex ? currentIndex : lastIndex + 1,
lastIndex > currentIndex ? lastIndex : currentIndex + 1
);
const newSelection: { [key: string]: boolean } = {};
const newSelection: Record<string, boolean> = {};
toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected();
});

View File

@ -1,6 +1,6 @@
export {
default,
createColumnHelper,
default,
type IConditionalStyle,
type RowSelectionState,
type VisibilityState

View File

@ -4,12 +4,12 @@
import { GraphCanvas as GraphUI } from 'reagraph';
export {
type CollapseProps,
type GraphCanvasRef,
type GraphEdge,
type GraphNode,
type GraphCanvasRef,
Sphere,
useSelection,
type CollapseProps
useSelection
} from 'reagraph';
export { type LayoutTypes as GraphLayout } from 'reagraph';

View File

@ -274,7 +274,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
onError: setProcessingError,
onSuccess: () =>
reloadItems(() => {
if (user && user.subscriptions.includes(target)) {
if (user?.subscriptions.includes(target)) {
user.subscriptions.splice(
user.subscriptions.findIndex(item => item === target),
1

View File

@ -102,7 +102,6 @@ export const OssState = ({ itemID, children }: OssStateProps) => {
return false;
}
return schema.subscribers.includes(user.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, schema, toggleTracking]);
useEffect(() => {

View File

@ -143,7 +143,6 @@ export const RSFormState = ({ itemID, versionID, children }: RSFormStateProps) =
return false;
}
return schema.subscribers.includes(user.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, schema, toggleTracking]);
const update = useCallback(

View File

@ -118,7 +118,7 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
/>
</TabPanel>
),
[alias, comment, title, attachedID, oss, createSchema]
[alias, comment, title, attachedID, oss, createSchema, setAlias]
);
const synthesisPanel = useMemo(
@ -137,7 +137,7 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
/>
</TabPanel>
),
[oss, alias, comment, title, inputs]
[oss, alias, comment, title, inputs, setAlias]
);
return (

View File

@ -61,7 +61,6 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
useEffect(() => {
cache.preload(schemasIDs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schemasIDs]);
const handleSubmit = () => {
@ -92,7 +91,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
/>
</TabPanel>
),
[alias, comment, title]
[alias, comment, title, setAlias]
);
const argumentsPanel = useMemo(
@ -106,7 +105,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
/>
</TabPanel>
),
[oss, target, inputs]
[oss, target, inputs, setInputs]
);
const synthesisPanel = useMemo(

View File

@ -73,7 +73,6 @@ function useRSFormCache() {
}
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending]);
return { preload, getSchema, getConstituenta, getSchemaByCst, loading, error, setError };

View File

@ -26,7 +26,6 @@ function useWindowSize() {
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return windowSize;

View File

@ -72,7 +72,7 @@ export class FolderNode {
*
*/
export class FolderTree {
roots: Map<string, FolderNode> = new Map();
roots = new Map<string, FolderNode>();
constructor(arr?: string[]) {
arr?.forEach(path => this.addPath(path));

View File

@ -48,7 +48,7 @@ export class GraphNode {
* This class is optimized for TermGraph use case and not supposed to be used as generic graph implementation.
*/
export class Graph {
nodes: Map<number, GraphNode> = new Map();
nodes = new Map<number, GraphNode>();
constructor(arr?: number[][]) {
if (!arr) {

View File

@ -20,7 +20,7 @@ import {
export class OssLoader {
private oss: IOperationSchemaData;
private graph: Graph = new Graph();
private operationByID: Map<OperationID, IOperation> = new Map();
private operationByID = new Map<OperationID, IOperation>();
private schemas: LibraryItemID[] = [];
constructor(input: IOperationSchemaData) {
@ -53,7 +53,7 @@ export class OssLoader {
}
private extractSchemas() {
this.schemas = this.oss.items.map(operation => operation.result as LibraryItemID).filter(item => item !== null);
this.schemas = this.oss.items.map(operation => operation.result).filter(item => item !== null);
}
private inferOperationAttributes() {

View File

@ -18,8 +18,8 @@ import { extractGlobals, isSimpleExpression, splitTemplateDefinition } from './r
export class RSFormLoader {
private schema: IRSFormData;
private graph: Graph = new Graph();
private cstByAlias: Map<string, IConstituenta> = new Map();
private cstByID: Map<ConstituentaID, IConstituenta> = new Map();
private cstByAlias = new Map<string, IConstituenta>();
private cstByID = new Map<ConstituentaID, IConstituenta>();
constructor(input: IRSFormData) {
this.schema = input;
@ -116,7 +116,7 @@ export class RSFormLoader {
}
private extractSources(target: IConstituenta): Set<ConstituentaID> {
const sources: Set<ConstituentaID> = new Set();
const sources = new Set<ConstituentaID>();
if (!isFunctional(target.cst_type)) {
const node = this.graph.at(target.id)!;
node.inputs.forEach(id => {

View File

@ -103,6 +103,7 @@ export interface ILibraryItemEditor {
isMutable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
setOwner: (newOwner: UserID) => void;
setAccessPolicy: (newPolicy: AccessPolicy) => void;

View File

@ -108,7 +108,7 @@ export enum HelpTopic {
/**
* Manual topics hierarchy.
*/
export const topicParent: Map<HelpTopic, HelpTopic> = new Map([
export const topicParent = new Map<HelpTopic, HelpTopic>([
[HelpTopic.MAIN, HelpTopic.MAIN],
[HelpTopic.INTERFACE, HelpTopic.INTERFACE],

View File

@ -254,7 +254,7 @@ export function isFunctional(type: CstType): boolean {
* Validate new alias against {@link CstType} and {@link IRSForm}.
*/
export function validateNewAlias(alias: string, type: CstType, schema: IRSForm): boolean {
return alias.length >= 2 && alias[0] == getCstTypePrefix(type) && !schema.cstByAlias.has(alias);
return alias.length >= 2 && alias.startsWith(getCstTypePrefix(type)) && !schema.cstByAlias.has(alias);
}
/**

View File

@ -91,7 +91,7 @@ export function substituteTemplateArgs(expression: string, args: IArgumentValue[
return expression;
}
const mapping: { [key: string]: string } = {};
const mapping: Record<string, string> = {};
args
.filter(arg => !!arg.value)
.forEach(arg => {

View File

@ -46,7 +46,7 @@ function FormCreateItem() {
const location = useMemo(() => combineLocation(head, body), [head, body]);
const isValid = useMemo(() => validateLocation(location), [location]);
const [initLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [initLocation, setInitLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [fileName, setFileName] = useState('');
const [file, setFile] = useState<File | undefined>();
@ -81,6 +81,7 @@ function FormCreateItem() {
file: file,
fileName: file?.name
};
setInitLocation(location);
createItem(data, newItem => {
toast.success(information.newLibraryItem);
if (itemType == LibraryItemType.RSFORM) {

View File

@ -1,6 +1,5 @@
import { HelpTopic } from '@/models/miscellaneous';
import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
function HelpCstAttributes() {
return (

View File

@ -1,15 +1,19 @@
import {
IconChild,
IconClone,
IconControls,
IconDestroy,
IconEdit,
IconFilter,
IconList,
IconMoveDown,
IconMoveUp,
IconNewItem,
IconOSS,
IconPredecessor,
IconReset,
IconSave,
IconSettings,
IconStatusOK,
IconText,
IconTree
@ -23,37 +27,64 @@ function HelpCstEditor() {
return (
<div className='dense'>
<h1>Редактор конституенты</h1>
<li>
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
</li>
<li>
<IconSave className='inline-icon' /> сохранить изменения: Ctrl + S
</li>
<li>
<IconReset className='inline-icon' /> сбросить несохраненные изменения
</li>
<li>
<IconClone className='inline-icon icon-green' /> клонировать текущую: Alt + V
</li>
<li>
<IconNewItem className='inline-icon icon-green' /> новая конституента
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> удаление текущей
</li>
<div className='flex flex-col sm:flex-row sm:gap-3'>
<div className='flex flex-col'>
<li>
<IconOSS className='inline-icon' /> переход к <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
</li>
<li>
<IconPredecessor className='inline-icon' /> переход к исходной
</li>
<li>
<IconList className='inline-icon' /> список конституент
</li>
<li>
<IconSave className='inline-icon' /> сохранить: Ctrl + S
</li>
<li>
<IconReset className='inline-icon' /> сбросить изменения
</li>
<li>
<IconClone className='inline-icon icon-green' /> клонировать: Alt + V
</li>
<li>
<IconNewItem className='inline-icon icon-green' /> новая конституента
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> удалить
</li>
</div>
<h2>Термин и Текстовое определение</h2>
<li>
<IconEdit className='inline-icon' /> кнопка переименования справа от{' '}
<LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} />
</li>
<li>
<IconEdit className='inline-icon' /> кнопка редактирования словоформ справа от{' '}
<LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
</li>
<li>Ctrl + Пробел открывает редактирование отсылок</li>
<div className='flex flex-col'>
<h2>Список конституент</h2>
<li>
<IconMoveDown className='inline-icon' />
<IconMoveUp className='inline-icon' /> Alt + вверх/вниз
</li>
<li>
<IconFilter className='inline-icon' />
<IconSettings className='inline-icon' /> фильтрация по графу термов
</li>
<li>
<IconChild className='inline-icon' /> отображение наследованных
</li>
<li>
<span style={{ backgroundColor: colors.bgSelected }}>текущая конституента</span>
</li>
<li>
<span style={{ backgroundColor: colors.bgGreen50 }}>
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
<li>
<span style={{ backgroundColor: colors.bgOrange50 }}>
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей
</span>
</li>
</div>
</div>
<h2>Определение понятия</h2>
<h2>Формальное определение</h2>
<li>
<IconStatusOK className='inline-icon' /> индикатор статуса определения сверху
</li>
@ -69,26 +100,12 @@ function HelpCstEditor() {
</li>
<li>Ctrl + Пробел дополняет до незанятого имени</li>
<h2>Список конституент</h2>
<h2>Термин и Текстовое определение</h2>
<li>
<IconList className='inline-icon' /> отображение списка конституент
</li>
<li>
<IconMoveDown className='inline-icon' />
<IconMoveUp className='inline-icon' /> Alt + вверх/вниз перемещение
</li>
<li>фильтрация в верхней части</li>
<li>
<span style={{ backgroundColor: colors.bgSelected }}>цветом фона</span> выделена текущая конституента
</li>
<li>
<span style={{ backgroundColor: colors.bgGreen50 }}>цветом фона</span> выделена{' '}
<LinkTopic text='основа' topic={HelpTopic.CC_RELATIONS} /> текущей
</li>
<li>
<span style={{ backgroundColor: colors.bgOrange50 }}>цветом фона</span> выделены{' '}
<LinkTopic text='порожденные' topic={HelpTopic.CC_RELATIONS} /> текущей
<IconEdit className='inline-icon' /> редактирование <LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} />{' '}
/ <LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
</li>
<li>Ctrl + Пробел открывает редактирование отсылок</li>
</div>
);
}

View File

@ -1,8 +1,101 @@
import {
IconAnimation,
IconAnimationOff,
IconConnect,
IconDestroy,
IconEdit2,
IconExecute,
IconFitImage,
IconGrid,
IconImage,
IconLineStraight,
IconLineWave,
IconNewItem,
IconNewRSForm,
IconReset,
IconRSForm,
IconSave
} from '@/components/Icons';
import Divider from '@/components/ui/Divider';
import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
function HelpOssGraph() {
return (
<div>
<div className='flex flex-col'>
<h1>Граф синтеза</h1>
<p>TBD.</p>
<div className='flex flex-col sm:flex-row'>
<div className='w-full sm:w-[14rem]'>
<h1>Настройка графа</h1>
<li>
<IconFitImage className='inline-icon' /> Вписать в экран
</li>
<li>
<IconGrid className='inline-icon' /> Отображение сетки
</li>
<li>
<IconLineWave className='inline-icon' />
<IconLineStraight className='inline-icon' /> Тип линии
</li>
<li>
<IconAnimation className='inline-icon' />
<IconAnimationOff className='inline-icon' /> Анимация
</li>
</div>
<Divider vertical margins='mx-3 mt-3' className='hidden sm:block' />
<div className='w-full sm:w-[21rem]'>
<h1>Изменение узлов</h1>
<li>Клик на операцию выделение</li>
<li>Esc сбросить выделение</li>
<li>
<IconEdit2 className='inline-icon' /> Двойной клик редактирование
</li>
<li>
<IconNewItem className='inline-icon icon-green' /> Новая операция
</li>
<li>
<IconDestroy className='inline-icon icon-red' /> Delete удалить выбранные
</li>
</div>
</div>
<Divider margins='my-3' className='hidden sm:block' />
<div className='flex flex-col-reverse mb-3 sm:flex-row'>
<div className='w-full sm:w-[14rem]'>
<h1>Общие</h1>
<li>
<IconReset className='inline-icon' /> Сбросить изменения
</li>
<li>
<IconSave className='inline-icon' /> Сохранить положения
</li>
<li>
<IconImage className='inline-icon' /> Сохранить в формат SVG
</li>
</div>
<Divider vertical margins='mx-3' className='hidden sm:block' />
<div className='dense w-[21rem]'>
<h1>Контекстное меню</h1>
<li>
<IconRSForm className='inline-icon icon-green' /> Переход к связанной{' '}
<LinkTopic text='КС' topic={HelpTopic.CC_SYSTEM} />
</li>
<li>
<IconNewRSForm className='inline-icon icon-green' /> Создать пустую КС для загрузки
</li>
<li>
<IconConnect className='inline-icon' /> Выбрать КС для загрузки
</li>
<li>
<IconExecute className='inline-icon icon-green' /> Выполнить (активировать) операцию
</li>
</div>
</div>
</div>
);
}

View File

@ -26,6 +26,7 @@ function HelpTermGraph() {
const { colors } = useConceptOptions();
return (
<div className='flex flex-col'>
<h1>Граф термов</h1>
<div className='flex flex-col sm:flex-row'>
<div className='w-full sm:w-[14rem]'>
<h1>Настройка графа</h1>
@ -78,7 +79,7 @@ function HelpTermGraph() {
<IconFilter className='inline-icon' /> Открыть настройки
</li>
<li>
<IconFitImage className='inline-icon' /> Вписать граф в экран
<IconFitImage className='inline-icon' /> Вписать в экран
</li>
<li>
<IconImage className='inline-icon' /> Сохранить в формат PNG

View File

@ -33,7 +33,7 @@ function InputNode(node: OssNodeInternal) {
disabled={!hasFile}
/>
</Overlay>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center text-sm'>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'>
{node.data.label}
{controller.showTooltip && !node.dragging ? (
<TooltipOperation anchor={`#${prefixes.operation_list}${node.id}`} node={node} />

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewItem, IconRSForm } from '@/components/Icons';
import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewRSForm, IconRSForm } from '@/components/Icons';
import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton';
import useClickedOutside from '@/hooks/useClickedOutside';
@ -124,7 +124,7 @@ function NodeContextMenu({
<DropdownButton
text='Создать схему'
title='Создать пустую схему для загрузки'
icon={<IconNewItem size='1rem' className='icon-green' />}
icon={<IconNewRSForm size='1rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={handleCreateSchema}
/>

View File

@ -33,7 +33,7 @@ function OperationNode(node: OssNodeInternal) {
/>
</Overlay>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center text-sm'>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center'>
{node.data.label}
{controller.showTooltip && !node.dragging ? (
<TooltipOperation anchor={`#${prefixes.operation_list}${node.id}`} node={node} />

View File

@ -24,7 +24,7 @@ import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useOSS } from '@/context/OssContext';
import useLocalStorage from '@/hooks/useLocalStorage';
import { OssNode } from '@/models/miscellaneous';
import { OperationID } from '@/models/oss';
import { OperationID, OperationType } from '@/models/oss';
import { PARAMETER, storage } from '@/utils/constants';
import { errors } from '@/utils/labels';
@ -127,19 +127,32 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
if (!controller.schema) {
return;
}
let target = { x: 0, y: 0 };
const positions = getPositions();
if (inputs.length <= 1) {
if (positions.length == 0) {
target = flow.project({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
}
if (inputs.length <= 1) {
let inputsNodes = positions.filter(pos =>
controller.schema!.items.find(
operation => operation.operation_type === OperationType.INPUT && operation.id === pos.id
)
);
if (inputsNodes.length > 0) {
inputsNodes = positions;
}
const maxX = Math.max(...inputsNodes.map(node => node.position_x));
const minY = Math.min(...inputsNodes.map(node => node.position_y));
target.x = maxX + 180;
target.y = minY;
} else {
const inputsNodes = positions.filter(pos => inputs.includes(pos.id));
const maxY = Math.max(...inputsNodes.map(node => node.position_y));
const minX = Math.min(...inputsNodes.map(node => node.position_x));
const maxX = Math.max(...inputsNodes.map(node => node.position_x));
target.y = maxY + 100;
target.x = Math.ceil((maxX + minX) / 2 / PARAMETER.ossGridSize) * PARAMETER.ossGridSize;
target.y = maxY + 100;
}
let flagIntersect = false;
@ -154,8 +167,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
target.y += PARAMETER.ossMinDistance;
}
} while (flagIntersect);
controller.promptCreateOperation(target.x, target.y, inputs, positions);
controller.promptCreateOperation({
x: target.x,
y: target.y,
inputs: inputs,
positions: positions,
callback: () => flow.fitView({ duration: PARAMETER.zoomDuration })
});
},
[controller, getPositions, flow]
);

View File

@ -15,7 +15,7 @@ import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors';
import DlgEditOperation from '@/dialogs/DlgEditOperation';
import { AccessPolicy, LibraryItemID } from '@/models/library';
import { AccessPolicy, ILibraryItemEditor, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous';
import {
IOperationCreateData,
@ -26,14 +26,24 @@ import {
OperationID
} from '@/models/oss';
import { UserID, UserLevel } from '@/models/user';
import { PARAMETER } from '@/utils/constants';
import { information } from '@/utils/labels';
export interface IOssEditContext {
export interface ICreateOperationPrompt {
x: number;
y: number;
inputs: OperationID[];
positions: IOperationPosition[];
callback: (newID: OperationID) => void;
}
export interface IOssEditContext extends ILibraryItemEditor {
schema?: IOperationSchema;
selected: OperationID[];
isMutable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
showTooltip: boolean;
setShowTooltip: React.Dispatch<React.SetStateAction<boolean>>;
@ -51,7 +61,7 @@ export interface IOssEditContext {
openOperationSchema: (target: OperationID) => void;
savePositions: (positions: IOperationPosition[], callback?: () => void) => void;
promptCreateOperation: (x: number, y: number, inputs: OperationID[], positions: IOperationPosition[]) => void;
promptCreateOperation: (props: ICreateOperationPrompt) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
createInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
@ -97,6 +107,8 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const [showCreateOperation, setShowCreateOperation] = useState(false);
const [insertPosition, setInsertPosition] = useState<Position2D>({ x: 0, y: 0 });
const [initialInputs, setInitialInputs] = useState<OperationID[]>([]);
const [createCallback, setCreateCallback] = useState<((newID: OperationID) => void) | undefined>(undefined);
const [positions, setPositions] = useState<IOperationPosition[]>([]);
const [targetOperationID, setTargetOperationID] = useState<OperationID | undefined>(undefined);
const targetOperation = useMemo(
@ -184,7 +196,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const openOperationSchema = useCallback(
(target: OperationID) => {
const node = model.schema?.operationByID.get(target);
if (!node || !node.result) {
if (!node?.result) {
return;
}
router.push(urls.schema(node.result));
@ -209,24 +221,27 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model]
);
const promptCreateOperation = useCallback(
(x: number, y: number, inputs: OperationID[], positions: IOperationPosition[]) => {
setInsertPosition({ x: x, y: y });
setInitialInputs(inputs);
setPositions(positions);
setShowCreateOperation(true);
},
[]
);
const promptCreateOperation = useCallback(({ x, y, inputs, positions, callback }: ICreateOperationPrompt) => {
setInsertPosition({ x: x, y: y });
setInitialInputs(inputs);
setPositions(positions);
setCreateCallback(() => callback);
setShowCreateOperation(true);
}, []);
const handleCreateOperation = useCallback(
(data: IOperationCreateData) => {
data.positions = positions;
data.item_data.position_x = insertPosition.x;
data.item_data.position_y = insertPosition.y;
model.createOperation(data, operation => toast.success(information.newOperation(operation.alias)));
model.createOperation(data, operation => {
toast.success(information.newOperation(operation.alias));
if (createCallback) {
setTimeout(() => createCallback(operation.id), PARAMETER.refreshTimeout);
}
});
},
[model, positions, insertPosition]
[model, positions, insertPosition, createCallback]
);
const promptEditOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => {
@ -305,6 +320,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
isMutable,
isProcessing: model.processing,
isAttachedToOSS: false,
toggleSubscribe,
setOwner,

View File

@ -135,7 +135,7 @@ function OssTabs() {
<TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}>
<MenuOssTabs onDestroy={onDestroySchema} />
<TabLabel label='Карточка' titleHtml={`Название: <b>${schema.title ?? ''}</b>`} />
<TabLabel label='Карточка' title={schema.title ?? ''} />
<TabLabel label='Граф' />
</TabList>

View File

@ -1,5 +1,7 @@
'use client';
import clsx from 'clsx';
import {
IconClone,
IconDestroy,
@ -54,7 +56,7 @@ function ToolbarConstituenta({
onSelect={(event, value) => controller.viewOSS(value.id, event.ctrlKey || event.metaKey)}
/>
) : null}
{activeCst && activeCst.is_inherited ? (
{activeCst?.is_inherited ? (
<MiniButton
title='Перейти к исходной конституенте в ОСС'
onClick={() => controller.viewPredecessor(activeCst.id)}
@ -118,7 +120,11 @@ function ToolbarConstituenta({
/>
</>
) : null}
<BadgeHelp topic={HelpTopic.UI_RS_EDITOR} offset={4} className={PARAMETER.TOOLTIP_WIDTH} />
<BadgeHelp
topic={HelpTopic.UI_RS_EDITOR}
offset={4}
className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')}
/>
</Overlay>
);
}

View File

@ -117,7 +117,7 @@ function EditorRSExpression({
);
const handleEdit = useCallback((id: TokenID, key?: string) => {
if (!rsInput.current || !rsInput.current.editor || !rsInput.current.state || !rsInput.current.view) {
if (!rsInput.current?.editor || !rsInput.current.state || !rsInput.current.view) {
return;
}
const text = new RSTextWrapper(rsInput.current as Required<ReactCodeMirrorRef>);
@ -145,12 +145,12 @@ function EditorRSExpression({
const controls = useMemo(
() => (
<RSEditorControls
isOpen={showControls && (!disabled || model.processing)}
isOpen={showControls && (!disabled || (model.processing && !activeCst?.is_inherited))}
disabled={disabled}
onEdit={handleEdit}
/>
),
[showControls, disabled, model.processing, handleEdit]
[showControls, disabled, model.processing, handleEdit, activeCst]
);
return (

View File

@ -4,6 +4,7 @@ import { useIntl } from 'react-intl';
import { IconEdit } from '@/components/Icons';
import InfoUsers from '@/components/info/InfoUsers';
import SelectUser from '@/components/select/SelectUser';
import LabeledValue from '@/components/ui/LabeledValue';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import Tooltip from '@/components/ui/Tooltip';
@ -15,8 +16,6 @@ import { UserID, UserLevel } from '@/models/user';
import { prefixes } from '@/utils/constants';
import { prompts } from '@/utils/labels';
import LabeledValue from '@/components/ui/LabeledValue';
interface EditorLibraryItemProps {
item?: ILibraryItemData;
isModified?: boolean;
@ -48,11 +47,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[2.3rem] cc-icons'>
<MiniButton
title='Изменить путь'
title={controller.isAttachedToOSS ? 'Путь наследуется от ОСС' : 'Изменить путь'}
noHover
onClick={() => controller.promptLocation()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
/>
</Overlay>
) : null}
@ -66,11 +65,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title='Изменить владельца'
title={controller.isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Изменить владельца'}
noHover
onClick={() => ownerSelector.toggle()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
/>
{ownerSelector.isOpen ? (
<SelectUser

View File

@ -33,7 +33,7 @@ function ToolbarItemAccess({ visible, toggleVisible, readOnly, toggleReadOnly, c
<Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'>
<SelectAccessPolicy
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing}
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing || controller.isAttachedToOSS}
value={policy}
onChange={newPolicy => controller.setAccessPolicy(newPolicy)}
/>

View File

@ -26,6 +26,7 @@ import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import {
AccessPolicy,
ILibraryItemEditor,
ILibraryUpdateData,
IVersionData,
LibraryItemID,
@ -55,13 +56,14 @@ import { promptUnsaved } from '@/utils/utils';
import { RSTabID } from './RSTabs';
export interface IRSEditContext {
export interface IRSEditContext extends ILibraryItemEditor {
schema?: IRSForm;
selected: ConstituentaID[];
isMutable: boolean;
isContentEditable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
canProduceStructure: boolean;
nothingSelected: boolean;
canDeleteSelected: boolean;
@ -153,6 +155,13 @@ export const RSEditState = ({
() => !nothingSelected && selected.every(id => !model.schema?.cstByID.get(id)?.is_inherited),
[selected, nothingSelected, model.schema]
);
const isAttachedToOSS = useMemo(
() =>
!!model.schema &&
model.schema.oss.length > 0 &&
(model.schema.stats.count_inherited > 0 || model.schema.items.length === 0),
[model.schema]
);
const [showUpload, setShowUpload] = useState(false);
const [showClone, setShowClone] = useState(false);
@ -378,7 +387,7 @@ export const RSEditState = ({
const oldCount = model.schema.items.length;
model.inlineSynthesis(data, newSchema => {
setSelected([]);
toast.success(information.addedConstituents(newSchema['items'].length - oldCount));
toast.success(information.addedConstituents(newSchema.items.length - oldCount));
});
},
[model, setSelected]
@ -618,6 +627,7 @@ export const RSEditState = ({
isMutable,
isContentEditable,
isProcessing: model.processing,
isAttachedToOSS,
canProduceStructure,
nothingSelected,
canDeleteSelected,

View File

@ -75,7 +75,7 @@ function RSTabs() {
setIsModified(false);
if (activeTab === RSTabID.CST_EDIT) {
const cstID = Number(cstQuery);
if (cstID && schema && schema.cstByID.has(cstID)) {
if (cstID && schema?.cstByID.has(cstID)) {
setSelected([cstID]);
} else if (schema && schema?.items.length > 0) {
setSelected([schema.items[0].id]);
@ -257,10 +257,7 @@ function RSTabs() {
<TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}>
<MenuRSTabs onDestroy={onDestroySchema} />
<TabLabel
label='Карточка'
titleHtml={`Название: <b>${schema.title ?? ''}</b><br />Версия: ${labelVersion(schema)}`}
/>
<TabLabel label='Карточка' titleHtml={`${schema.title ?? ''}<br />Версия: ${labelVersion(schema)}`} />
<TabLabel
label='Содержание'
titleHtml={`Конституент: ${schema.stats?.count_all ?? 0}<br />Ошибок: ${schema.stats?.count_errors ?? 0}`}

View File

@ -2,8 +2,10 @@
import { useLayoutEffect, useMemo, useState } from 'react';
import { IconChild } from '@/components/Icons';
import SelectGraphFilter from '@/components/select/SelectGraphFilter';
import SelectMatchMode from '@/components/select/SelectMatchMode';
import MiniButton from '@/components/ui/MiniButton';
import SearchBar from '@/components/ui/SearchBar';
import useLocalStorage from '@/hooks/useLocalStorage';
import { CstMatchMode, DependencyMode } from '@/models/miscellaneous';
@ -25,6 +27,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
const [filterMatch, setFilterMatch] = useLocalStorage(storage.cstFilterMatch, CstMatchMode.ALL);
const [filterSource, setFilterSource] = useLocalStorage(storage.cstFilterGraph, DependencyMode.ALL);
const [filterText, setFilterText] = useState('');
const [showInherited, setShowInherited] = useLocalStorage(storage.cstFilterShowInherited, true);
useLayoutEffect(() => {
if (!schema || schema.items.length === 0) {
@ -48,8 +51,21 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
if (filterText) {
result = result.filter(cst => matchConstituenta(cst, filterText, filterMatch));
}
if (!showInherited) {
result = result.filter(cst => !cst.is_inherited);
}
setFiltered(result);
}, [filterText, setFiltered, filterSource, activeExpression, schema?.items, schema, filterMatch, activeID]);
}, [
filterText,
setFiltered,
filterSource,
activeExpression,
schema?.items,
schema,
filterMatch,
activeID,
showInherited
]);
const selectGraph = useMemo(
() => <SelectGraphFilter value={filterSource} onChange={newValue => setFilterSource(newValue)} dense={dense} />,
@ -72,6 +88,15 @@ function ConstituentsSearch({ schema, activeID, activeExpression, dense, setFilt
/>
{selectMatchMode}
{selectGraph}
{schema && schema?.stats.count_inherited > 0 ? (
<MiniButton
noHover
titleHtml={`Наследованные: <b>${showInherited ? 'отображать' : 'скрывать'}</b>`}
icon={<IconChild size='1rem' className={showInherited ? 'icon-primary' : 'clr-text-controls'} />}
className='h-fit self-center'
onClick={() => setShowInherited(prev => !prev)}
/>
) : null}
</div>
);
}

View File

@ -53,7 +53,7 @@ function TableSideConstituents({
useLayoutEffect(() => {
setColumnVisibility(prev => {
const newValue = (windowSize.width ?? 0) >= denseThreshold;
if (newValue === prev['expression']) {
if (newValue === prev.expression) {
return prev;
} else {
return { expression: newValue };

View File

@ -66,6 +66,8 @@
border: 1px solid;
padding: 2px;
width: 150px;
height: 30px;
font-size: 14px;
border-radius: 5px;
background-color: var(--cl-bg-120);

View File

@ -37,11 +37,11 @@ function cursorNode({ type, from, to }: TreeCursor, isLeaf = false): CursorNode
return { type, from, to, isLeaf };
}
type TreeTraversalOptions = {
interface TreeTraversalOptions {
beforeEnter?: (cursor: TreeCursor) => void;
onEnter: (node: CursorNode) => false | void;
onLeave?: (node: CursorNode) => false | void;
};
}
/**
* Implements depth-first traversal.

View File

@ -17,6 +17,8 @@ export const PARAMETER = {
ossContextMenuWidth: 200, // pixels - width of OSS context menu
ossGridSize: 10, // pixels - size of OSS grid
ossMinDistance: 20, // pixels - minimum distance between node centers
ossDistanceX: 180, // pixels - insert x-distance between node centers
ossDistanceY: 100, // pixels - insert y-distance between node centers
graphHoverXLimit: 0.4, // ratio to clientWidth used to determine which side of screen popup should be
graphHoverYLimit: 0.6, // ratio to clientHeight used to determine which side of screen popup should be
@ -121,7 +123,8 @@ export const storage = {
ossEdgeAnimate: 'oss.edge_animate',
cstFilterMatch: 'cst.filter.match',
cstFilterGraph: 'cst.filter.graph'
cstFilterGraph: 'cst.filter.graph',
cstFilterShowInherited: 'cst.filter.show_inherited'
};
/**

View File

@ -296,7 +296,7 @@ export function describeLocationHead(head: LocationHead): string {
/**
* Retrieves label for graph layout mode.
*/
export const mapLabelLayout: Map<GraphLayout, string> = new Map([
export const mapLabelLayout = new Map<GraphLayout, string>([
['treeTd2d', 'Граф: ДеревоВ 2D'],
['treeTd3d', 'Граф: ДеревоВ 3D'],
['forceatlas2', 'Граф: Атлас 2D'],
@ -312,7 +312,7 @@ export const mapLabelLayout: Map<GraphLayout, string> = new Map([
/**
* Retrieves label for {@link GraphColoring}.
*/
export const mapLabelColoring: Map<GraphColoring, string> = new Map([
export const mapLabelColoring = new Map<GraphColoring, string>([
['none', 'Цвет: Моно'],
['status', 'Цвет: Статус'],
['type', 'Цвет: Класс']
@ -321,7 +321,7 @@ export const mapLabelColoring: Map<GraphColoring, string> = new Map([
/**
* Retrieves label for {@link GraphSizing}.
*/
export const mapLabelSizing: Map<GraphSizing, string> = new Map([
export const mapLabelSizing = new Map<GraphSizing, string>([
['none', 'Узлы: Моно'],
['derived', 'Узлы: Порожденные'],
['complex', 'Узлы: Простые']

View File

@ -29,14 +29,14 @@ export class TextMatcher {
}
try {
this.query = new RegExp(query, isCaseSensitive ? '' : 'i');
} catch (exception: unknown) {
} catch (_exception: unknown) {
this.query = query;
}
}
test(text: string): boolean {
if (typeof this.query === 'string') {
return text.indexOf(this.query) !== -1;
return text.includes(this.query);
} else {
return !!text.match(this.query);
}
@ -46,7 +46,7 @@ export class TextMatcher {
/**
* Text substitution guided by mapping and regular expression.
*/
export function applyPattern(text: string, mapping: { [key: string]: string }, pattern: RegExp): string {
export function applyPattern(text: string, mapping: Record<string, string>, pattern: RegExp): string {
if (text === '' || pattern === null) {
return text;
}