Compare commits

..

No commits in common. "513d6a5b71773bbebef4b2926adb99e9edcbf0ff" and "27ba4a5da8f1ec157475ec386d08ba818e43502a" have entirely different histories.

83 changed files with 6325 additions and 11760 deletions

View File

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

View File

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

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

View File

@ -16,6 +16,7 @@ from django.db.models import (
from apps.users.models import User
from .Editor import Editor
from .Subscription import Subscription
from .Version import Version
@ -114,19 +115,18 @@ class LibraryItem(Model):
def get_absolute_url(self):
return f'/api/library/{self.pk}'
def subscribers(self) -> QuerySet[User]:
def subscribers(self) -> list[User]:
''' Get all subscribers for this item. '''
return User.objects.filter(subscription__item=self.pk)
return [subscription.user for subscription in Subscription.objects.filter(item=self.pk).only('user')]
def editors(self) -> QuerySet[User]:
def editors(self) -> list[User]:
''' Get all Editors of this item. '''
return User.objects.filter(editor__item=self.pk)
return [item.editor for item in Editor.objects.filter(item=self.pk).only('editor')]
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_id, item=self.pk)
Subscription.subscribe(user=self.owner, item=self)
def _update_connected_operations(self):
# using method level import to prevent circular dependency

View File

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

View File

@ -1,8 +1,13 @@
''' 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. '''
@ -27,17 +32,17 @@ class Subscription(Model):
return f'{self.user} -> {self.item}'
@staticmethod
def subscribe(user: int, item: int) -> bool:
def subscribe(user: User, item: 'LibraryItem') -> bool:
''' Add subscription. '''
if Subscription.objects.filter(user_id=user, item_id=item).exists():
if Subscription.objects.filter(user=user, item=item).exists():
return False
Subscription.objects.create(user_id=user, item_id=item)
Subscription.objects.create(user=user, item=item)
return True
@staticmethod
def unsubscribe(user: int, item: int) -> bool:
def unsubscribe(user: User, item: 'LibraryItem') -> bool:
''' Remove subscription. '''
sub = Subscription.objects.filter(user_id=user, item_id=item).only('pk')
sub = Subscription.objects.filter(user=user, item=item)
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 list(instance.subscribers().values_list('pk', flat=True))
return [item.pk for item in instance.subscribers()]
def get_editors(self, instance: LibraryItem) -> list[int]:
return list(instance.editors().values_list('pk', flat=True))
return [item.pk for item in instance.editors()]
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().only('pk'))
user = PKField(many=False, queryset=User.objects.all())
class UsersListSerializer(serializers.Serializer):
''' Serializer: List of Users. '''
users = PKField(many=True, queryset=User.objects.all().only('pk'))
users = PKField(many=True, queryset=User.objects.all())

View File

@ -34,65 +34,44 @@ class TestEditor(TestCase):
def test_add_editor(self):
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.assertTrue(Editor.add(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(self.user1 in self.item.editors())
self.assertFalse(Editor.add(self.item.pk, self.user1.pk))
self.assertEqual(self.item.editors().count(), 1)
self.assertFalse(Editor.add(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(Editor.add(self.item.pk, self.user2.pk))
self.assertEqual(self.item.editors().count(), 2)
self.assertTrue(Editor.add(self.item, self.user2))
self.assertEqual(len(self.item.editors()), 2)
self.assertTrue(self.user1 in self.item.editors())
self.assertTrue(self.user2 in self.item.editors())
self.user1.delete()
self.assertEqual(self.item.editors().count(), 1)
self.assertEqual(len(self.item.editors()), 1)
def test_remove_editor(self):
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.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.assertTrue(Editor.remove(self.item.pk, self.user1.pk))
self.assertEqual(self.item.editors().count(), 1)
self.assertTrue(Editor.remove(self.item, self.user1))
self.assertEqual(len(self.item.editors()), 1)
self.assertTrue(self.user2 in self.item.editors())
self.assertFalse(Editor.remove(self.item.pk, self.user1.pk))
self.assertFalse(Editor.remove(self.item, self.user1))
def test_set_editors(self):
Editor.set(self.item.pk, [self.user1.pk])
self.assertEqual(list(self.item.editors()), [self.user1])
Editor.set(self.item, [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.user1, self.user1])
self.assertEqual(self.item.editors(), [self.user1])
Editor.set(self.item.pk, [])
self.assertEqual(list(self.item.editors()), [])
Editor.set(self.item, [])
self.assertEqual(self.item.editors(), [])
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, [])
Editor.set(self.item, [self.user1, self.user2])
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(item.subscribers().count(), 0)
self.assertEqual(len(item.subscribers()), 0)
self.assertTrue(Subscription.subscribe(self.user1.pk, item.pk))
self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(self.user1 in item.subscribers())
self.assertFalse(Subscription.subscribe(self.user1.pk, item.pk))
self.assertEqual(item.subscribers().count(), 1)
self.assertFalse(Subscription.subscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(Subscription.subscribe(self.user2.pk, item.pk))
self.assertEqual(item.subscribers().count(), 2)
self.assertTrue(Subscription.subscribe(self.user2, item))
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(self.user1 in item.subscribers())
self.assertTrue(self.user2 in item.subscribers())
self.user1.delete()
self.assertEqual(item.subscribers().count(), 1)
self.assertEqual(len(item.subscribers()), 1)
def test_unsubscribe(self):
item = LibraryItem.objects.create(item_type=LibraryItemType.RSFORM, title='Test')
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.assertFalse(Subscription.unsubscribe(self.user1, item))
Subscription.subscribe(self.user1, item)
Subscription.subscribe(self.user2, item)
self.assertEqual(len(item.subscribers()), 2)
self.assertTrue(Subscription.unsubscribe(self.user1.pk, item.pk))
self.assertEqual(item.subscribers().count(), 1)
self.assertTrue(Subscription.unsubscribe(self.user1, item))
self.assertEqual(len(item.subscribers()), 1)
self.assertTrue(self.user2 in item.subscribers())
self.assertFalse(Subscription.unsubscribe(self.user1.pk, item.pk))
self.assertFalse(Subscription.unsubscribe(self.user1, item))

View File

@ -183,6 +183,57 @@ 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
@ -197,18 +248,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(list(self.owned.editors()), [self.user])
self.assertEqual(self.owned.editors(), [self.user])
self.executeOK(data=data)
self.assertEqual(list(self.owned.editors()), [self.user])
self.assertEqual(self.owned.editors(), [self.user])
data = {'users': [self.user2.pk]}
self.executeOK(data=data)
self.assertEqual(list(self.owned.editors()), [self.user2])
self.assertEqual(self.owned.editors(), [self.user2])
data = {'users': []}
self.executeOK(data=data)
self.assertEqual(list(self.owned.editors()), [])
self.assertEqual(self.owned.editors(), [])
data = {'users': [self.user2.pk, self.user.pk]}
self.executeOK(data=data)
@ -269,9 +320,9 @@ class TestLibraryViewset(EndpointTester):
response = self.executeOK()
self.assertFalse(response_contains(response, self.unowned))
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)
Subscription.subscribe(user=self.user, item=self.unowned)
Subscription.subscribe(user=self.user2, item=self.unowned)
Subscription.subscribe(user=self.user2, item=self.owned)
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 generics
from rest_framework import filters, 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,8 +27,10 @@ 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):
@ -50,6 +52,8 @@ class LibraryViewSet(viewsets.ModelViewSet):
'set_owner',
'set_access_policy',
'set_location',
'add_editor',
'remove_editor',
'set_editors'
]:
access_level = permissions.ItemOwner
@ -125,7 +129,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(int, self.request.user.pk), item=item.pk)
m.Subscription.subscribe(user=cast(User, self.request.user), item=item)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -142,7 +146,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(int, self.request.user.pk), item=item.pk)
m.Subscription.unsubscribe(user=cast(User, self.request.user), item=item)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -161,53 +165,8 @@ 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'].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'])
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='set location for item',
tags=['Library'],
request=s.LocationSerializer,
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-location')
def set_location(self, request: Request, pk):
''' Endpoint: Set item location. '''
item = self._get_item()
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)
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'])
new_owner = serializer.validated_data['user']
m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner)
return Response(status=c.HTTP_200_OK)
@extend_schema(
@ -228,18 +187,70 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer = s.AccessPolicySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_policy = serializer.validated_data['access_policy']
if new_policy == item.access_policy:
return Response(status=c.HTTP_200_OK)
m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=new_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='set location for item',
tags=['Library'],
request=s.LocationSerializer,
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-location')
def set_location(self, request: Request, pk):
''' Endpoint: Set item location. '''
item = self._get_item()
serializer = s.LocationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
location: str = serializer.validated_data['location']
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)
return Response(status=c.HTTP_200_OK)
@extend_schema(
summary='add 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='add-editor')
def add_editor(self, request: Request, pk):
''' Endpoint: Add editor for item. '''
item = self._get_item()
serializer = s.UserTargetSerializer(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)
@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(
@ -258,32 +269,8 @@ class LibraryViewSet(viewsets.ModelViewSet):
item = self._get_item()
serializer = s.UsersListSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
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)
editors = serializer.validated_data['users']
m.Editor.set(item=item, users=editors)
return Response(status=c.HTTP_200_OK)

View File

@ -25,14 +25,6 @@ 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

@ -1,20 +0,0 @@
# 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,12 +4,6 @@ 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,14 +51,6 @@ 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}
@ -169,7 +161,7 @@ class OperationSchema:
access_policy=self.model.access_policy,
location=self.model.location
)
Editor.set(schema.model.pk, self.model.editors().values_list('pk', flat=True))
Editor.set(schema.model, self.model.editors())
operation.result = schema.model
operation.save()
self.save()
@ -205,7 +197,6 @@ 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().only('pk'), required=False)
arguments = PKField(many=True, queryset=Operation.objects.all(), 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().only('oss_id', 'result_id'), required=False)
arguments = PKField(many=True, queryset=Operation.objects.all(), required=False)
substitutions = serializers.ListField(
child=SubstitutionSerializerBase(),
required=False
@ -121,8 +121,8 @@ class OperationUpdateSerializer(serializers.Serializer):
class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
''' Serializer: Delete operation. '''
target = PKField(many=False, queryset=Operation.objects.all())
positions = serializers.ListField(
child=OperationPositionSerializer(),
default=[]

View File

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

View File

@ -1,51 +0,0 @@
''' 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,3 +1,2 @@
''' Tests for REST API. '''
from .t_change_attributes import *
from .t_oss import *

View File

@ -1,125 +0,0 @@
''' 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.pk, self.user2.pk)
Editor.add(self.owned.model, self.user2)
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().only('schema_id'))
items = PKField(many=True, queryset=Constituenta.objects.all())
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_id'))
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema_id'))
original = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema'))
substitution = PKField(many=False, queryset=Constituenta.objects.only('alias', 'schema'))
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().only('owner_id'))
source = PKField(many=False, queryset=LibraryItem.objects.all().only('owner_id')) # type: ignore
receiver = PKField(many=False, queryset=LibraryItem.objects.all())
source = PKField(many=False, queryset=LibraryItem.objects.all()) # 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,8 +250,9 @@ 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,7 +1,4 @@
''' 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
@ -43,9 +40,6 @@ 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(
@ -67,9 +61,9 @@ class EndpointTester(APITestCase):
def toggle_editor(self, item: LibraryItem, value: bool = True):
if value:
Editor.add(item.pk, self.user.pk)
Editor.add(item, self.user)
else:
Editor.remove(item.pk, self.user.pk)
Editor.remove(item, self.user)
def login(self):
self.client.force_authenticate(user=self.user)
@ -77,16 +71,6 @@ 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

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

View File

@ -0,0 +1,25 @@
{
"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

@ -1,61 +0,0 @@
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 . --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.20.1",
"@tanstack/react-table": "^8.19.3",
"@uiw/codemirror-themes": "^4.23.0",
"@uiw/react-codemirror": "^4.23.0",
"axios": "^1.7.3",
"axios": "^1.7.2",
"clsx": "^2.1.1",
"framer-motion": "^11.3.21",
"framer-motion": "^11.3.19",
"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.26.0",
"react-router-dom": "^6.25.1",
"react-select": "^5.8.0",
"react-tabs": "^6.0.2",
"react-toastify": "^10.0.5",
"react-tooltip": "^5.28.0",
"react-tooltip": "^5.27.1",
"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": "^22.1.0",
"@types/node": "^20.14.13",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.8.0",
"eslint-plugin-react": "^7.35.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.9",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.9.0",
"eslint-plugin-tsdoc": "^0.3.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: 122 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -1,7 +1,6 @@
// 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';
@ -62,11 +61,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 { TbHexagons as IconOSS } from 'react-icons/tb';
export { TbHexagon as IconRSForm } from 'react-icons/tb';
export { GrInherit as IconChild } from 'react-icons/gr';
export { GiHoneycomb as IconOSS } from 'react-icons/gi';
export { LuBaby as IconChild } from 'react-icons/lu';
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';
@ -100,7 +99,6 @@ 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 interface Titled {
export type Titled = {
title?: string;
titleHtml?: string;
hideTitle?: boolean;
}
};
export type Control = Titled & {
disabled?: boolean;
@ -14,18 +14,18 @@ export namespace CProps {
noOutline?: boolean;
};
export interface Styling {
export type Styling = {
style?: React.CSSProperties;
className?: string;
}
};
export type Editor = Control & {
label?: string;
};
export interface Colors {
export type Colors = {
colors?: string;
}
};
export type Div = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export type Button = Titled &

View File

@ -17,36 +17,25 @@ 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={onToggle}
onClick={() => ossMenu.toggle()}
/>
{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}
<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>
</div>
);
}

View File

@ -140,11 +140,13 @@ 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-left'>{props.getValue()}</div>
cell: props => <div className='min-w-[10.5rem] text-ellipsis text-right'>{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 ? (
@ -155,11 +157,13 @@ 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 ? (
@ -170,8 +174,9 @@ 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 text-right'>{props.getValue()}</div>
cell: props => <div className='min-w-[8rem] text-ellipsis'>{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?.hasPredecessor(item)));
setFolded(items.filter(item => item !== activeNode && (!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?.value !== undefined) onSelectValue(data.value);
if (data !== null && 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 { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
export { createColumnHelper, type ColumnSort, 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: Record<string, boolean> = {};
const newSelection: { [key: string]: boolean } = {};
toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected();
});

View File

@ -1,6 +1,6 @@
export {
createColumnHelper,
default,
createColumnHelper,
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
useSelection,
type CollapseProps
} 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?.subscriptions.includes(target)) {
if (user && user.subscriptions.includes(target)) {
user.subscriptions.splice(
user.subscriptions.findIndex(item => item === target),
1

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ 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 = new Map<string, FolderNode>();
roots: Map<string, FolderNode> = new Map();
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 = new Map<number, GraphNode>();
nodes: Map<number, GraphNode> = new Map();
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 = new Map<OperationID, IOperation>();
private operationByID: Map<OperationID, IOperation> = new Map();
private schemas: LibraryItemID[] = [];
constructor(input: IOperationSchemaData) {
@ -53,7 +53,7 @@ export class OssLoader {
}
private extractSchemas() {
this.schemas = this.oss.items.map(operation => operation.result).filter(item => item !== null);
this.schemas = this.oss.items.map(operation => operation.result as LibraryItemID).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 = new Map<string, IConstituenta>();
private cstByID = new Map<ConstituentaID, IConstituenta>();
private cstByAlias: Map<string, IConstituenta> = new Map();
private cstByID: Map<ConstituentaID, IConstituenta> = new Map();
constructor(input: IRSFormData) {
this.schema = input;
@ -116,7 +116,7 @@ export class RSFormLoader {
}
private extractSources(target: IConstituenta): Set<ConstituentaID> {
const sources = new Set<ConstituentaID>();
const sources: Set<ConstituentaID> = new Set();
if (!isFunctional(target.cst_type)) {
const node = this.graph.at(target.id)!;
node.inputs.forEach(id => {

View File

@ -103,7 +103,6 @@ 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 = new Map<HelpTopic, HelpTopic>([
export const topicParent: Map<HelpTopic, HelpTopic> = new Map([
[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.startsWith(getCstTypePrefix(type)) && !schema.cstByAlias.has(alias);
return alias.length >= 2 && alias[0] == getCstTypePrefix(type) && !schema.cstByAlias.has(alias);
}
/**

View File

@ -91,7 +91,7 @@ export function substituteTemplateArgs(expression: string, args: IArgumentValue[
return expression;
}
const mapping: Record<string, string> = {};
const mapping: { [key: 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, setInitLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [initLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [fileName, setFileName] = useState('');
const [file, setFile] = useState<File | undefined>();
@ -81,7 +81,6 @@ 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,7 @@
import LinkTopic from '@/components/ui/LinkTopic';
import { HelpTopic } from '@/models/miscellaneous';
import LinkTopic from '@/components/ui/LinkTopic';
function HelpCstAttributes() {
return (
<div className='dense'>

View File

@ -1,19 +1,15 @@
import {
IconChild,
IconClone,
IconControls,
IconDestroy,
IconEdit,
IconFilter,
IconList,
IconMoveDown,
IconMoveUp,
IconNewItem,
IconOSS,
IconPredecessor,
IconReset,
IconSave,
IconSettings,
IconStatusOK,
IconText,
IconTree
@ -27,64 +23,37 @@ function HelpCstEditor() {
return (
<div className='dense'>
<h1>Редактор конституенты</h1>
<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>
<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'>
<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>
<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>
<h2>Формальное определение</h2>
<h2>Определение понятия</h2>
<li>
<IconStatusOK className='inline-icon' /> индикатор статуса определения сверху
</li>
@ -100,12 +69,26 @@ function HelpCstEditor() {
</li>
<li>Ctrl + Пробел дополняет до незанятого имени</li>
<h2>Термин и Текстовое определение</h2>
<h2>Список конституент</h2>
<li>
<IconEdit className='inline-icon' /> редактирование <LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} />{' '}
/ <LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
<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} /> текущей
</li>
<li>Ctrl + Пробел открывает редактирование отсылок</li>
</div>
);
}

View File

@ -1,101 +1,8 @@
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 className='flex flex-col'>
<div>
<h1>Граф синтеза</h1>
<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>
<p>TBD.</p>
</div>
);
}

View File

@ -26,7 +26,6 @@ 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>
@ -79,7 +78,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'>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center text-sm'>
{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, IconNewRSForm, IconRSForm } from '@/components/Icons';
import { IconConnect, IconDestroy, IconEdit2, IconExecute, IconNewItem, 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={<IconNewRSForm size='1rem' className='icon-green' />}
icon={<IconNewItem 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'>
<div id={`${prefixes.operation_list}${node.id}`} className='flex-grow text-center text-sm'>
{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, OperationType } from '@/models/oss';
import { OperationID } from '@/models/oss';
import { PARAMETER, storage } from '@/utils/constants';
import { errors } from '@/utils/labels';
@ -127,32 +127,19 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
if (!controller.schema) {
return;
}
let target = { x: 0, y: 0 };
const positions = getPositions();
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;
target = flow.project({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
} 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.x = Math.ceil((maxX + minX) / 2 / PARAMETER.ossGridSize) * PARAMETER.ossGridSize;
target.y = maxY + 100;
target.x = Math.ceil((maxX + minX) / 2 / PARAMETER.ossGridSize) * PARAMETER.ossGridSize;
}
let flagIntersect = false;
@ -167,13 +154,8 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
target.y += PARAMETER.ossMinDistance;
}
} while (flagIntersect);
controller.promptCreateOperation({
x: target.x,
y: target.y,
inputs: inputs,
positions: positions,
callback: () => flow.fitView({ duration: PARAMETER.zoomDuration })
});
controller.promptCreateOperation(target.x, target.y, inputs, positions);
},
[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, ILibraryItemEditor, LibraryItemID } from '@/models/library';
import { AccessPolicy, LibraryItemID } from '@/models/library';
import { Position2D } from '@/models/miscellaneous';
import {
IOperationCreateData,
@ -26,24 +26,14 @@ import {
OperationID
} from '@/models/oss';
import { UserID, UserLevel } from '@/models/user';
import { PARAMETER } from '@/utils/constants';
import { information } from '@/utils/labels';
export interface ICreateOperationPrompt {
x: number;
y: number;
inputs: OperationID[];
positions: IOperationPosition[];
callback: (newID: OperationID) => void;
}
export interface IOssEditContext extends ILibraryItemEditor {
export interface IOssEditContext {
schema?: IOperationSchema;
selected: OperationID[];
isMutable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
showTooltip: boolean;
setShowTooltip: React.Dispatch<React.SetStateAction<boolean>>;
@ -61,7 +51,7 @@ export interface IOssEditContext extends ILibraryItemEditor {
openOperationSchema: (target: OperationID) => void;
savePositions: (positions: IOperationPosition[], callback?: () => void) => void;
promptCreateOperation: (props: ICreateOperationPrompt) => void;
promptCreateOperation: (x: number, y: number, inputs: OperationID[], positions: IOperationPosition[]) => void;
deleteOperation: (target: OperationID, positions: IOperationPosition[]) => void;
createInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
@ -107,8 +97,6 @@ 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(
@ -196,7 +184,7 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
const openOperationSchema = useCallback(
(target: OperationID) => {
const node = model.schema?.operationByID.get(target);
if (!node?.result) {
if (!node || !node.result) {
return;
}
router.push(urls.schema(node.result));
@ -221,27 +209,24 @@ export const OssEditState = ({ selected, setSelected, children }: OssEditStatePr
[model]
);
const promptCreateOperation = useCallback(({ x, y, inputs, positions, callback }: ICreateOperationPrompt) => {
setInsertPosition({ x: x, y: y });
setInitialInputs(inputs);
setPositions(positions);
setCreateCallback(() => callback);
setShowCreateOperation(true);
}, []);
const promptCreateOperation = useCallback(
(x: number, y: number, inputs: OperationID[], positions: IOperationPosition[]) => {
setInsertPosition({ x: x, y: y });
setInitialInputs(inputs);
setPositions(positions);
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));
if (createCallback) {
setTimeout(() => createCallback(operation.id), PARAMETER.refreshTimeout);
}
});
model.createOperation(data, operation => toast.success(information.newOperation(operation.alias)));
},
[model, positions, insertPosition, createCallback]
[model, positions, insertPosition]
);
const promptEditOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => {
@ -320,7 +305,6 @@ 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='Карточка' title={schema.title ?? ''} />
<TabLabel label='Карточка' titleHtml={`Название: <b>${schema.title ?? ''}</b>`} />
<TabLabel label='Граф' />
</TabList>

View File

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

View File

@ -117,7 +117,7 @@ function EditorRSExpression({
);
const handleEdit = useCallback((id: TokenID, key?: string) => {
if (!rsInput.current?.editor || !rsInput.current.state || !rsInput.current.view) {
if (!rsInput.current || !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 && !activeCst?.is_inherited))}
isOpen={showControls && (!disabled || model.processing)}
disabled={disabled}
onEdit={handleEdit}
/>
),
[showControls, disabled, model.processing, handleEdit, activeCst]
[showControls, disabled, model.processing, handleEdit]
);
return (

View File

@ -4,7 +4,6 @@ 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';
@ -16,6 +15,8 @@ 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;
@ -47,11 +48,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[2.3rem] cc-icons'>
<MiniButton
title={controller.isAttachedToOSS ? 'Путь наследуется от ОСС' : 'Изменить путь'}
title='Изменить путь'
noHover
onClick={() => controller.promptLocation()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
disabled={isModified || controller.isProcessing}
/>
</Overlay>
) : null}
@ -65,11 +66,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title={controller.isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Изменить владельца'}
title='Изменить владельца'
noHover
onClick={() => ownerSelector.toggle()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing || controller.isAttachedToOSS}
disabled={isModified || controller.isProcessing}
/>
{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 || controller.isAttachedToOSS}
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing}
value={policy}
onChange={newPolicy => controller.setAccessPolicy(newPolicy)}
/>

View File

@ -26,7 +26,6 @@ import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import {
AccessPolicy,
ILibraryItemEditor,
ILibraryUpdateData,
IVersionData,
LibraryItemID,
@ -56,14 +55,13 @@ import { promptUnsaved } from '@/utils/utils';
import { RSTabID } from './RSTabs';
export interface IRSEditContext extends ILibraryItemEditor {
export interface IRSEditContext {
schema?: IRSForm;
selected: ConstituentaID[];
isMutable: boolean;
isContentEditable: boolean;
isProcessing: boolean;
isAttachedToOSS: boolean;
canProduceStructure: boolean;
nothingSelected: boolean;
canDeleteSelected: boolean;
@ -155,13 +153,6 @@ 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);
@ -387,7 +378,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]
@ -627,7 +618,6 @@ 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?.cstByID.has(cstID)) {
if (cstID && schema && schema.cstByID.has(cstID)) {
setSelected([cstID]);
} else if (schema && schema?.items.length > 0) {
setSelected([schema.items[0].id]);
@ -257,7 +257,10 @@ 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={`${schema.title ?? ''}<br />Версия: ${labelVersion(schema)}`} />
<TabLabel
label='Карточка'
titleHtml={`Название: <b>${schema.title ?? ''}</b><br />Версия: ${labelVersion(schema)}`}
/>
<TabLabel
label='Содержание'
titleHtml={`Конституент: ${schema.stats?.count_all ?? 0}<br />Ошибок: ${schema.stats?.count_errors ?? 0}`}

View File

@ -2,10 +2,8 @@
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';
@ -27,7 +25,6 @@ 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) {
@ -51,21 +48,8 @@ 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,
showInherited
]);
}, [filterText, setFiltered, filterSource, activeExpression, schema?.items, schema, filterMatch, activeID]);
const selectGraph = useMemo(
() => <SelectGraphFilter value={filterSource} onChange={newValue => setFilterSource(newValue)} dense={dense} />,
@ -88,15 +72,6 @@ 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,8 +66,6 @@
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 };
}
interface TreeTraversalOptions {
type TreeTraversalOptions = {
beforeEnter?: (cursor: TreeCursor) => void;
onEnter: (node: CursorNode) => false | void;
onLeave?: (node: CursorNode) => false | void;
}
};
/**
* Implements depth-first traversal.

View File

@ -17,8 +17,6 @@ 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
@ -123,8 +121,7 @@ export const storage = {
ossEdgeAnimate: 'oss.edge_animate',
cstFilterMatch: 'cst.filter.match',
cstFilterGraph: 'cst.filter.graph',
cstFilterShowInherited: 'cst.filter.show_inherited'
cstFilterGraph: 'cst.filter.graph'
};
/**

View File

@ -296,7 +296,7 @@ export function describeLocationHead(head: LocationHead): string {
/**
* Retrieves label for graph layout mode.
*/
export const mapLabelLayout = new Map<GraphLayout, string>([
export const mapLabelLayout: Map<GraphLayout, string> = new Map([
['treeTd2d', 'Граф: ДеревоВ 2D'],
['treeTd3d', 'Граф: ДеревоВ 3D'],
['forceatlas2', 'Граф: Атлас 2D'],
@ -312,7 +312,7 @@ export const mapLabelLayout = new Map<GraphLayout, string>([
/**
* Retrieves label for {@link GraphColoring}.
*/
export const mapLabelColoring = new Map<GraphColoring, string>([
export const mapLabelColoring: Map<GraphColoring, string> = new Map([
['none', 'Цвет: Моно'],
['status', 'Цвет: Статус'],
['type', 'Цвет: Класс']
@ -321,7 +321,7 @@ export const mapLabelColoring = new Map<GraphColoring, string>([
/**
* Retrieves label for {@link GraphSizing}.
*/
export const mapLabelSizing = new Map<GraphSizing, string>([
export const mapLabelSizing: Map<GraphSizing, string> = new Map([
['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.includes(this.query);
return text.indexOf(this.query) !== -1;
} 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: Record<string, string>, pattern: RegExp): string {
export function applyPattern(text: string, mapping: { [key: string]: string }, pattern: RegExp): string {
if (text === '' || pattern === null) {
return text;
}