Implementing locations and permissions pt1

This commit is contained in:
IRBorisov 2024-06-02 23:41:46 +03:00
parent 64e1b028bc
commit dc0555076b
71 changed files with 1884 additions and 855 deletions

View File

@ -16,11 +16,11 @@ class LibraryItemAdmin(admin.ModelAdmin):
date_hierarchy = 'time_update'
list_display = [
'alias', 'title', 'owner',
'is_common', 'is_canonical',
'visible', 'read_only', 'access_policy', 'location',
'time_update'
]
list_filter = ['is_common', 'is_canonical', 'time_update']
search_fields = ['alias', 'title']
list_filter = ['visible', 'read_only', 'access_policy', 'location', 'time_update']
search_fields = ['alias', 'title', 'location']
class LibraryTemplateAdmin(admin.ModelAdmin):
@ -44,6 +44,15 @@ class SubscriptionAdmin(admin.ModelAdmin):
]
class EditorAdmin(admin.ModelAdmin):
''' Admin model: Editors. '''
list_display = ['id', 'item', 'editor']
search_fields = [
'item__title', 'item__alias',
'editor__username', 'editor__first_name', 'editor__last_name'
]
class VersionAdmin(admin.ModelAdmin):
''' Admin model: Versions. '''
list_display = ['id', 'item', 'version', 'description', 'time_create']
@ -57,3 +66,4 @@ admin.site.register(models.LibraryItem, LibraryItemAdmin)
admin.site.register(models.LibraryTemplate, LibraryTemplateAdmin)
admin.site.register(models.Subscription, SubscriptionAdmin)
admin.site.register(models.Version, VersionAdmin)
admin.site.register(models.Editor, EditorAdmin)

View File

@ -30,6 +30,14 @@ def aliasTaken(name: str):
return f'Имя уже используется: {name}'
def invalidLocation():
return f'Некорректная строка расположения'
def invalidEnum(value: str):
return f'Неподдерживаемое значение параметра: {value}'
def pyconceptFailure():
return 'Invalid data response from pyconcept'

View File

@ -0,0 +1,65 @@
# Hand written migration 20240531
from django.db import migrations, models
from .. import models as m
class Migration(migrations.Migration):
dependencies = [
('rsform', '0006_editor'),
]
def calculate_location(apps, schema_editor):
LibraryItem = apps.get_model('rsform', 'LibraryItem')
db_alias = schema_editor.connection.alias
for item in LibraryItem.objects.using(db_alias).all():
if item.is_canonical:
location = m.LocationHead.LIBRARY
elif item.is_common:
location = m.LocationHead.COMMON
else:
location = m.LocationHead.USER
item.location = location
item.save()
operations = [
migrations.AddField(
model_name='libraryitem',
name='access_policy',
field=models.CharField(
choices=[
('public', 'Public'),
('protected', 'Protected'),
('private', 'Private')
],
default='public',
max_length=500,
verbose_name='Политика доступа'),
),
migrations.AddField(
model_name='libraryitem',
name='location',
field=models.TextField(default='/U', max_length=500, verbose_name='Расположение'),
),
migrations.AddField(
model_name='libraryitem',
name='read_only',
field=models.BooleanField(default=False, verbose_name='Запретить редактирование'),
),
migrations.AddField(
model_name='libraryitem',
name='visible',
field=models.BooleanField(default=True, verbose_name='Отображаемая'),
),
migrations.RunPython(calculate_location, migrations.RunPython.noop), # type: ignore
migrations.RemoveField(
model_name='libraryitem',
name='is_canonical',
),
migrations.RemoveField(
model_name='libraryitem',
name='is_common',
),
]

View File

@ -1,4 +1,6 @@
''' Models: LibraryItem. '''
import re
from django.db import transaction
from django.db.models import (
SET_NULL,
@ -24,6 +26,28 @@ class LibraryItemType(TextChoices):
OPERATIONS_SCHEMA = 'oss'
class AccessPolicy(TextChoices):
''' Type of item access policy. '''
PUBLIC = 'public'
PROTECTED = 'protected'
PRIVATE = 'private'
class LocationHead(TextChoices):
''' Location prefixes. '''
PROJECTS = '/P'
LIBRARY = '/L'
USER = '/U'
COMMON = '/S'
_RE_LOCATION = r'^/[PLUS]((/[!\d\w]([!\d\w ]*[!\d\w])?)*)?$' # cspell:disable-line
def validate_location(target: str) -> bool:
return bool(re.search(_RE_LOCATION, target))
class LibraryItem(Model):
''' Abstract library item.'''
item_type: CharField = CharField(
@ -49,14 +73,26 @@ class LibraryItem(Model):
verbose_name='Комментарий',
blank=True
)
is_common: BooleanField = BooleanField(
verbose_name='Общая',
visible: BooleanField = BooleanField(
verbose_name='Отображаемая',
default=True
)
read_only: BooleanField = BooleanField(
verbose_name='Запретить редактирование',
default=False
)
is_canonical: BooleanField = BooleanField(
verbose_name='Каноничная',
default=False
access_policy: CharField = CharField(
verbose_name='Политика доступа',
max_length=500,
choices=AccessPolicy.choices,
default=AccessPolicy.PUBLIC
)
location: TextField = TextField(
verbose_name='Расположение',
max_length=500,
default=LocationHead.USER
)
time_create: DateTimeField = DateTimeField(
verbose_name='Дата создания',
auto_now_add=True

View File

@ -3,7 +3,14 @@
from .api_RSForm import RSForm
from .Constituenta import Constituenta, CstType, _empty_forms
from .Editor import Editor
from .LibraryItem import LibraryItem, LibraryItemType, User
from .LibraryItem import (
AccessPolicy,
LibraryItem,
LibraryItemType,
LocationHead,
User,
validate_location
)
from .LibraryTemplate import LibraryTemplate
from .Subscription import Subscription
from .Version import Version

View File

@ -57,10 +57,11 @@ class ItemEditor(ItemOwner):
''' Item permission: Editor or higher. '''
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
item = _extract_item(obj)
if m.Editor.objects.filter(
item=_extract_item(obj),
item=item,
editor=cast(m.User, request.user)
).exists():
).exists() and item.access_policy != m.AccessPolicy.PRIVATE:
return True
return super().has_object_permission(request, view, obj)

View File

@ -1,9 +1,11 @@
''' REST API: Serializers. '''
from .basics import (
AccessPolicySerializer,
ASTNodeSerializer,
ExpressionParseSerializer,
ExpressionSerializer,
LocationSerializer,
MultiFormSerializer,
ResolverSerializer,
TextSerializer,
@ -18,6 +20,7 @@ from .data_access import (
CstSubstituteSerializer,
CstTargetSerializer,
InlineSynthesisSerializer,
LibraryItemBase,
LibraryItemCloneSerializer,
LibraryItemSerializer,
RSFormParseSerializer,

View File

@ -4,6 +4,9 @@ from typing import cast
from cctext import EntityReference, Reference, ReferenceType, Resolver, SyntacticReference
from rest_framework import serializers
from .. import messages as msg
from ..models import AccessPolicy, validate_location
class ExpressionSerializer(serializers.Serializer):
''' Serializer: RSLang expression. '''
@ -16,6 +19,32 @@ class WordFormSerializer(serializers.Serializer):
grams = serializers.CharField()
class LocationSerializer(serializers.Serializer):
''' Serializer: Item location. '''
location = serializers.CharField(max_length=500)
def validate(self, attrs):
attrs = super().validate(attrs)
if not validate_location(attrs['location']):
raise serializers.ValidationError({
'location': msg.invalidLocation()
})
return attrs
class AccessPolicySerializer(serializers.Serializer):
''' Serializer: Constituenta renaming. '''
access_policy = serializers.CharField(max_length=500)
def validate(self, attrs):
attrs = super().validate(attrs)
if not attrs['access_policy'] in AccessPolicy.values:
raise serializers.ValidationError({
'access_policy': msg.invalidEnum(attrs['access_policy'])
})
return attrs
class MultiFormSerializer(serializers.Serializer):
''' Serializer: inflect request. '''
items = serializers.ListField(

View File

@ -13,8 +13,8 @@ from .basics import CstParseSerializer
from .io_pyconcept import PyConceptAdapter
class LibraryItemSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem entry. '''
class LibraryItemBase(serializers.ModelSerializer):
''' Serializer: LibraryItem entry full access. '''
class Meta:
''' serializer metadata. '''
model = LibraryItem
@ -22,6 +22,15 @@ class LibraryItemSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'item_type')
class LibraryItemSerializer(serializers.ModelSerializer):
''' Serializer: LibraryItem entry limited access. '''
class Meta:
''' serializer metadata. '''
model = LibraryItem
fields = '__all__'
read_only_fields = ('id', 'item_type', 'owner', 'location', 'access_policy')
class VersionSerializer(serializers.ModelSerializer):
''' Serializer: Version data. '''
class Meta:
@ -164,8 +173,11 @@ class RSFormSerializer(serializers.ModelSerializer):
del result['editors']
del result['owner']
del result['is_common']
del result['is_canonical']
del result['visible']
del result['read_only']
del result['access_policy']
del result['location']
del result['time_create']
del result['time_update']
return result
@ -208,7 +220,7 @@ class RSFormSerializer(serializers.ModelSerializer):
validated_data=new_cst.validated_data
)
loaded_item = LibraryItemSerializer(data=data)
loaded_item = LibraryItemBase(data=data)
loaded_item.is_valid(raise_exception=True)
loaded_item.update(
instance=cast(LibraryItem, self.instance),
@ -325,7 +337,7 @@ class CstListSerializer(serializers.Serializer):
return attrs
class LibraryItemCloneSerializer(LibraryItemSerializer):
class LibraryItemCloneSerializer(LibraryItemBase):
''' Serializer: LibraryItem cloning. '''
items = PKField(many=True, required=False, queryset=Constituenta.objects.all())

View File

@ -109,10 +109,14 @@ class RSFormTRSSerializer(serializers.Serializer):
result = super().to_internal_value(data)
if 'owner' in data:
result['owner'] = data['owner']
if 'is_common' in data:
result['is_common'] = data['is_common']
if 'is_canonical' in data:
result['is_canonical'] = data['is_canonical']
if 'visible' in data:
result['visible'] = data['visible']
if 'read_only' in data:
result['read_only'] = data['read_only']
if 'access_policy' in data:
result['access_policy'] = data['access_policy']
if 'location' in data:
result['location'] = data['location']
result['items'] = data.get('items', [])
if self.context['load_meta']:
result['title'] = data.get('title', 'Без названия')
@ -139,8 +143,10 @@ class RSFormTRSSerializer(serializers.Serializer):
alias=validated_data['alias'],
title=validated_data['title'],
comment=validated_data['comment'],
is_common=validated_data['is_common'],
is_canonical=validated_data['is_canonical']
visible=validated_data['visible'],
read_only=validated_data['read_only'],
access_policy=validated_data['access_policy'],
location=validated_data['location']
)
self.instance.item.save()
order = 1

View File

@ -2,6 +2,7 @@
from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
from apps.rsform.models import Editor, LibraryItem
from apps.users.models import User
@ -43,6 +44,12 @@ class EndpointTester(APITestCase):
self.user.is_staff = value
self.user.save()
def toggle_editor(self, item: LibraryItem, value: bool = True):
if value:
Editor.add(item, self.user)
else:
Editor.remove(item, self.user)
def login(self):
self.client.force_authenticate(user=self.user)

View File

@ -1,7 +1,15 @@
''' Testing models: LibraryItem. '''
from django.test import TestCase
from apps.rsform.models import LibraryItem, LibraryItemType, Subscription, User
from apps.rsform.models import (
AccessPolicy,
LibraryItem,
LibraryItemType,
LocationHead,
Subscription,
User,
validate_location
)
class TestLibraryItem(TestCase):
@ -40,8 +48,10 @@ class TestLibraryItem(TestCase):
self.assertEqual(item.title, 'Test')
self.assertEqual(item.alias, '')
self.assertEqual(item.comment, '')
self.assertEqual(item.is_common, False)
self.assertEqual(item.is_canonical, False)
self.assertEqual(item.visible, True)
self.assertEqual(item.read_only, False)
self.assertEqual(item.access_policy, AccessPolicy.PUBLIC)
self.assertEqual(item.location, LocationHead.USER)
def test_create(self):
@ -51,13 +61,39 @@ class TestLibraryItem(TestCase):
owner=self.user1,
alias='KS1',
comment='Test comment',
is_common=True,
is_canonical=True
location=LocationHead.COMMON
)
self.assertEqual(item.owner, self.user1)
self.assertEqual(item.title, 'Test')
self.assertEqual(item.alias, 'KS1')
self.assertEqual(item.comment, 'Test comment')
self.assertEqual(item.is_common, True)
self.assertEqual(item.is_canonical, True)
self.assertEqual(item.location, LocationHead.COMMON)
self.assertTrue(Subscription.objects.filter(user=item.owner, item=item).exists())
class TestLocation(TestCase):
''' Testing Location model. '''
def test_validate_location(self):
self.assertFalse(validate_location(''))
self.assertFalse(validate_location('/A'))
self.assertFalse(validate_location('U/U'))
self.assertFalse(validate_location('/U/'))
self.assertFalse(validate_location('/U/user@mail'))
self.assertFalse(validate_location('/U/u\\asdf'))
self.assertFalse(validate_location('/U/ asdf'))
self.assertFalse(validate_location('/User'))
self.assertFalse(validate_location('//'))
self.assertFalse(validate_location('/S/1/'))
self.assertFalse(validate_location('/S/1 '))
self.assertFalse(validate_location('/S/1/2 /3'))
self.assertTrue(validate_location('/P'))
self.assertTrue(validate_location('/L'))
self.assertTrue(validate_location('/U'))
self.assertTrue(validate_location('/S'))
self.assertTrue(validate_location('/S/1'))
self.assertTrue(validate_location('/S/12'))
self.assertTrue(validate_location('/S/12/3'))
self.assertTrue(validate_location('/S/Вася пупки'))
self.assertTrue(validate_location('/S/1/!asdf/тест тест'))

View File

@ -2,10 +2,12 @@
from rest_framework import status
from apps.rsform.models import (
AccessPolicy,
Editor,
LibraryItem,
LibraryItemType,
LibraryTemplate,
LocationHead,
RSForm,
Subscription
)
@ -35,7 +37,7 @@ class TestLibraryViewset(EndpointTester):
item_type=LibraryItemType.RSFORM,
title='Test3',
alias='T3',
is_common=True
location=LocationHead.COMMON
)
self.invalid_user = 1337 + self.user2.pk
self.invalid_item = 1337 + self.common.pk
@ -55,15 +57,37 @@ class TestLibraryViewset(EndpointTester):
@decl_endpoint('/api/library/{item}', method='patch')
def test_update(self):
data = {'id': self.unowned.pk, 'title': 'New title'}
data = {'id': self.unowned.pk, 'title': 'New Title'}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
data = {'id': self.owned.pk, 'title': 'New title'}
self.toggle_editor(self.unowned, True)
response = self.executeOK(data, item=self.unowned.pk)
self.assertEqual(response.data['title'], data['title'])
self.unowned.access_policy = AccessPolicy.PRIVATE
self.unowned.save()
self.executeForbidden(data, item=self.unowned.pk)
data = {'id': self.owned.pk, 'title': 'New Title'}
response = self.executeOK(data, item=self.owned.pk)
self.assertEqual(response.data['title'], 'New title')
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], self.owned.alias)
data = {
'id': self.owned.pk,
'title': 'Another Title',
'owner': self.user2.pk,
'access_policy': AccessPolicy.PROTECTED,
'location': LocationHead.LIBRARY
}
response = self.executeOK(data, item=self.owned.pk)
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['owner'], self.owned.owner.pk)
self.assertEqual(response.data['access_policy'], self.owned.access_policy)
self.assertEqual(response.data['location'], self.owned.location)
self.assertNotEqual(response.data['location'], LocationHead.LIBRARY)
@decl_endpoint('/api/library/{item}/set-owner', method='patch')
def test_set_owner(self):
@ -89,6 +113,59 @@ class TestLibraryViewset(EndpointTester):
self.owned.refresh_from_db()
self.assertEqual(self.owned.owner, self.user)
@decl_endpoint('/api/library/{item}/set-access-policy', method='patch')
def test_set_access_policy(self):
time_update = self.owned.time_update
data = {'access_policy': 'invalid'}
self.executeBadData(data, item=self.owned.pk)
data = {'access_policy': AccessPolicy.PRIVATE}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.access_policy, data['access_policy'])
self.toggle_editor(self.unowned, True)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_admin(True)
self.executeOK(data, item=self.unowned.pk)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.access_policy, data['access_policy'])
@decl_endpoint('/api/library/{item}/set-location', method='patch')
def test_set_location(self):
time_update = self.owned.time_update
data = {'location': 'invalid'}
self.executeBadData(data, item=self.owned.pk)
data = {'location': '/U/temp'}
self.executeNotFound(data, item=self.invalid_item)
self.executeForbidden(data, item=self.unowned.pk)
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.location, data['location'])
data = {'location': LocationHead.LIBRARY}
self.executeForbidden(data, item=self.owned.pk)
data = {'location': '/U/temp'}
self.toggle_editor(self.unowned, True)
self.executeForbidden(data, item=self.unowned.pk)
self.toggle_admin(True)
data = {'location': LocationHead.LIBRARY}
self.executeOK(data, item=self.owned.pk)
self.owned.refresh_from_db()
self.assertEqual(self.owned.location, data['location'])
self.executeOK(data, item=self.unowned.pk)
self.unowned.refresh_from_db()
self.assertEqual(self.unowned.location, data['location'])
@decl_endpoint('/api/library/{item}/editors-add', method='patch')
def test_add_editor(self):
time_update = self.owned.time_update

View File

@ -6,7 +6,15 @@ from zipfile import ZipFile
from cctext import ReferenceType
from rest_framework import status
from apps.rsform.models import Constituenta, CstType, LibraryItem, LibraryItemType, RSForm
from apps.rsform.models import (
AccessPolicy,
Constituenta,
CstType,
LibraryItem,
LibraryItemType,
LocationHead,
RSForm
)
from ..EndpointTester import EndpointTester, decl_endpoint
from ..testing_utils import response_contains
@ -38,12 +46,21 @@ class TestRSFormViewset(EndpointTester):
@decl_endpoint('/api/rsforms/create-detailed', method='post')
def test_create_rsform_json(self):
data = {'title': 'Test123', 'comment': '123', 'alias': 'ks1'}
data = {
'title': 'Test123',
'comment': '123',
'alias': 'ks1',
'location': LocationHead.PROJECTS,
'access_policy': AccessPolicy.PROTECTED,
'visible': False
}
response = self.executeCreated(data)
self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], 'Test123')
self.assertEqual(response.data['alias'], 'ks1')
self.assertEqual(response.data['comment'], '123')
self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], data['alias'])
self.assertEqual(response.data['location'], data['location'])
self.assertEqual(response.data['access_policy'], data['access_policy'])
self.assertEqual(response.data['visible'], data['visible'])
@decl_endpoint('/api/rsforms', method='get')

View File

@ -27,12 +27,26 @@ class LibraryActiveView(generics.ListAPIView):
def get_queryset(self):
if self.request.user.is_anonymous:
return m.LibraryItem.objects.filter(is_common=True).order_by('-time_update')
return m.LibraryItem.objects.filter(
Q(access_policy=m.AccessPolicy.PUBLIC),
).filter(
Q(location__startswith=m.LocationHead.COMMON) |
Q(location__startswith=m.LocationHead.LIBRARY)
).order_by('-time_update')
else:
user = cast(m.User, self.request.user)
# pylint: disable=unsupported-binary-operation
return m.LibraryItem.objects.filter(
Q(is_common=True) | Q(owner=user) | Q(subscription__user=user)
(
Q(access_policy=m.AccessPolicy.PUBLIC) &
(
Q(location__startswith=m.LocationHead.COMMON) |
Q(location__startswith=m.LocationHead.LIBRARY)
)
) |
Q(owner=user) |
Q(editor__editor=user) |
Q(subscription__user=user)
).distinct().order_by('-time_update')
@ -68,8 +82,8 @@ class LibraryViewSet(viewsets.ModelViewSet):
serializer_class = s.LibraryItemSerializer
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_fields = ['item_type', 'owner', 'is_common', 'is_canonical']
ordering_fields = ('item_type', 'owner', 'title', 'time_update')
filterset_fields = ['item_type', 'owner']
ordering_fields = ('item_type', 'owner', 'alias', 'title', 'time_update')
ordering = '-time_update'
def perform_create(self, serializer):
@ -82,7 +96,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
if self.action in ['update', 'partial_update']:
permission_list = [permissions.ItemEditor]
elif self.action in [
'destroy', 'set_owner',
'destroy', 'set_owner', 'set_access_policy', 'set_location',
'editors_add', 'editors_remove', 'editors_set'
]:
permission_list = [permissions.ItemOwner]
@ -119,12 +133,13 @@ class LibraryViewSet(viewsets.ModelViewSet):
clone.title = serializer.validated_data['title']
clone.alias = serializer.validated_data.get('alias', '')
clone.comment = serializer.validated_data.get('comment', '')
clone.is_common = serializer.validated_data.get('is_common', False)
clone.is_canonical = False
if clone.item_type == m.LibraryItemType.RSFORM:
clone.visible = serializer.validated_data.get('visible', True)
clone.read_only = False
clone.access_policy = serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC)
clone.location = serializer.validated_data.get('location', m.LocationHead.USER)
clone.save()
if clone.item_type == m.LibraryItemType.RSFORM:
need_filter = 'items' in request.data
for cst in m.RSForm(item).constituents():
if not need_filter or cst.pk in request.data['items']:
@ -191,6 +206,49 @@ class LibraryViewSet(viewsets.ModelViewSet):
m.LibraryItem.objects.filter(pk=item.pk).update(owner=new_owner)
return Response(status=c.HTTP_200_OK)
@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)
m.LibraryItem.objects.filter(pk=item.pk).update(access_policy=serializer.validated_data['access_policy'])
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.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'],

View File

@ -447,15 +447,17 @@ def create_rsform(request: Request):
''' Endpoint: Create RSForm from user input and/or trs file. '''
owner = cast(m.User, request.user) if not request.user.is_anonymous else None
if 'file' not in request.FILES:
serializer = s.LibraryItemSerializer(data=request.data)
serializer = s.LibraryItemBase(data=request.data)
serializer.is_valid(raise_exception=True)
schema = m.RSForm.create(
title=serializer.validated_data['title'],
owner=owner,
alias=serializer.validated_data.get('alias', ''),
comment=serializer.validated_data.get('comment', ''),
is_common=serializer.validated_data.get('is_common', False),
is_canonical=serializer.validated_data.get('is_canonical', False),
visible=serializer.validated_data.get('visible', True),
read_only=serializer.validated_data.get('read_only', False),
access_policy=serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC),
location=serializer.validated_data.get('location', m.LocationHead.USER),
)
else:
data = utils.read_zipped_json(request.FILES['file'].file, utils.EXTEOR_INNER_FILENAME)
@ -481,12 +483,15 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[m.User, None
if 'comment' in request.data and request.data['comment'] != '':
data['comment'] = request.data['comment']
is_common = True
if 'is_common' in request.data:
is_common = request.data['is_common'] == 'true'
data['is_common'] = is_common
visible = True
if 'visible' in request.data:
visible = request.data['visible'] == 'true'
data['visible'] = visible
is_canonical = False
if 'is_canonical' in request.data:
is_canonical = request.data['is_canonical'] == 'true'
data['is_canonical'] = is_canonical
read_only = False
if 'read_only' in request.data:
read_only = request.data['read_only'] == 'true'
data['read_only'] = read_only
data['access_policy'] = request.data.get('access_policy', m.AccessPolicy.PUBLIC)
data['location'] = request.data.get('location', m.LocationHead.USER)

View File

@ -11,7 +11,7 @@ function ApplicationLayout() {
const { viewportHeight, mainHeight, showScroll } = useConceptOptions();
return (
<NavigationState>
<div className='min-w-[20rem] overflow-x-auto max-w-[100vw] clr-app antialiased'>
<div className='min-w-[20rem] clr-app antialiased'>
<ConceptToaster
className='mt-[4rem] text-sm' // prettier: split lines
autoClose={3000}
@ -23,13 +23,13 @@ function ApplicationLayout() {
<div
id={globals.main_scroll}
className='cc-scroll-y min-w-fit'
className='cc-scroll-y flex flex-col items-start overflow-x-auto max-w-[100vw]'
style={{
maxHeight: viewportHeight,
overflowY: showScroll ? 'scroll' : 'auto'
}}
>
<main className='flex flex-col items-center' style={{ minHeight: mainHeight }}>
<main className='flex flex-col items-center w-full' style={{ minHeight: mainHeight }}>
<Outlet />
</main>
<Footer />

View File

@ -15,6 +15,7 @@ function Footer() {
tabIndex={-1}
className={clsx(
'z-navigation',
'w-full',
'sm:px-4 sm:py-2 flex flex-col items-center gap-1',
'text-xs sm:text-sm select-none whitespace-nowrap'
)}

View File

@ -1,6 +1,6 @@
import { createBrowserRouter } from 'react-router-dom';
import CreateRSFormPage from '@/pages/CreateRSFormPage';
import CreateItemPage from '@/pages/CreateRSFormPage';
import HomePage from '@/pages/HomePage';
import LibraryPage from '@/pages/LibraryPage';
import LoginPage from '@/pages/LoginPage';
@ -51,7 +51,7 @@ export const Router = createBrowserRouter([
},
{
path: routes.create_schema,
element: <CreateRSFormPage />
element: <CreateItemPage />
},
{
path: `${routes.rsforms}/:id`,

View File

@ -7,7 +7,8 @@ import { toast } from 'react-toastify';
import { type ErrorData } from '@/components/info/InfoError';
import { ILexemeData, IResolutionData, ITextRequest, ITextResult, IWordFormPlain } from '@/models/language';
import { ILibraryItem, ILibraryUpdateData, IVersionData } from '@/models/library';
import { ILibraryItem, ILibraryUpdateData, ITargetAccessPolicy, ITargetLocation, IVersionData } from '@/models/library';
import { ILibraryCreateData } from '@/models/library';
import {
IConstituentaList,
IConstituentaMeta,
@ -20,7 +21,6 @@ import {
IInlineSynthesisData,
IProduceStructureResponse,
IRSFormCloneData,
IRSFormCreateData,
IRSFormData,
IRSFormUploadData,
ITargetCst,
@ -199,7 +199,7 @@ export function getTemplates(request: FrontPull<ILibraryItem[]>) {
});
}
export function postNewRSForm(request: FrontExchange<IRSFormCreateData, ILibraryItem>) {
export function postNewRSForm(request: FrontExchange<ILibraryCreateData, ILibraryItem>) {
AxiosPost({
endpoint: '/api/rsforms/create-detailed',
request: request,
@ -253,6 +253,20 @@ export function patchSetOwner(target: string, request: FrontPush<ITargetUser>) {
});
}
export function patchSetAccessPolicy(target: string, request: FrontPush<ITargetAccessPolicy>) {
AxiosPatch({
endpoint: `/api/library/${target}/set-access-policy`,
request: request
});
}
export function patchSetLocation(target: string, request: FrontPush<ITargetLocation>) {
AxiosPatch({
endpoint: `/api/library/${target}/set-location`,
request: request
});
}
export function patchEditorsAdd(target: string, request: FrontPush<ITargetUser>) {
AxiosPatch({
endpoint: `/api/library/${target}/editors-add`,

View File

@ -0,0 +1,104 @@
import { AccessPolicy, LocationHead } from '@/models/library';
import { CstMatchMode, DependencyMode } from '@/models/miscellaneous';
import {
IconAlias,
IconBusiness,
IconFilter,
IconFollow,
IconFollowOff,
IconFormula,
IconGraphCollapse,
IconGraphExpand,
IconGraphInputs,
IconGraphOutputs,
IconHide,
IconPrivate,
IconProps,
IconProtected,
IconPublic,
IconSettings,
IconShow,
IconTemplates,
IconTerm,
IconText,
IconUser
} from './Icons';
export interface DomIconProps<RequestData> extends IconProps {
value: RequestData;
}
export function PolicyIcon({ value, size = '1.25rem', className }: DomIconProps<AccessPolicy>) {
switch (value) {
case AccessPolicy.PRIVATE:
return <IconPrivate size={size} className={className ?? 'clr-text-red'} />;
case AccessPolicy.PROTECTED:
return <IconProtected size={size} className={className ?? 'clr-text-primary'} />;
case AccessPolicy.PUBLIC:
return <IconPublic size={size} className={className ?? 'clr-text-green'} />;
}
}
export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconShow size={size} className={className ?? 'clr-text-green'} />;
} else {
return <IconHide size={size} className={className ?? 'clr-text-red'} />;
}
}
export function SubscribeIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconFollow size={size} className={className ?? 'clr-text-green'} />;
} else {
return <IconFollowOff size={size} className={className ?? 'clr-text-red'} />;
}
}
export function LocationHeadIcon({ value: value, size = '1.25rem', className }: DomIconProps<LocationHead>) {
switch (value) {
case undefined:
return <IconFilter size={size} className={className ?? 'clr-text-primary'} />;
case LocationHead.COMMON:
return <IconPublic size={size} className={className ?? 'clr-text-primary'} />;
case LocationHead.LIBRARY:
return <IconTemplates size={size} className={className ?? 'clr-text-primary'} />;
case LocationHead.PROJECTS:
return <IconBusiness size={size} className={className ?? 'clr-text-primary'} />;
case LocationHead.USER:
return <IconUser size={size} className={className ?? 'clr-text-primary'} />;
}
}
export function DependencyIcon(mode: DependencyMode, size: string, color?: string) {
switch (mode) {
case DependencyMode.ALL:
return <IconSettings size={size} className={color} />;
case DependencyMode.EXPRESSION:
return <IconText size={size} className={color} />;
case DependencyMode.OUTPUTS:
return <IconGraphOutputs size={size} className={color} />;
case DependencyMode.INPUTS:
return <IconGraphInputs size={size} className={color} />;
case DependencyMode.EXPAND_OUTPUTS:
return <IconGraphExpand size={size} className={color} />;
case DependencyMode.EXPAND_INPUTS:
return <IconGraphCollapse size={size} className={color} />;
}
}
export function MatchModeIcon(mode: CstMatchMode, size: string, color?: string) {
switch (mode) {
case CstMatchMode.ALL:
return <IconFilter size={size} className={color} />;
case CstMatchMode.TEXT:
return <IconText size={size} className={color} />;
case CstMatchMode.EXPR:
return <IconFormula size={size} className={color} />;
case CstMatchMode.TERM:
return <IconTerm size={size} className={color} />;
case CstMatchMode.NAME:
return <IconAlias size={size} className={color} />;
}
}

View File

@ -15,6 +15,8 @@ export { BiSearchAlt2 as IconSearch } from 'react-icons/bi';
export { BiDownload as IconDownload } from 'react-icons/bi';
export { BiUpload as IconUpload } from 'react-icons/bi';
export { BiCog as IconSettings } from 'react-icons/bi';
export { TbEye as IconShow } from 'react-icons/tb';
export { TbEyeX as IconHide } from 'react-icons/tb';
export { BiShareAlt as IconShare } from 'react-icons/bi';
export { BiFilterAlt as IconFilter } from 'react-icons/bi';
export {BiDownArrowCircle as IconOpenList } from 'react-icons/bi';
@ -28,6 +30,7 @@ export { RiMenuFoldFill as IconMenuFold } from 'react-icons/ri';
export { RiMenuUnfoldFill as IconMenuUnfold } from 'react-icons/ri';
export { LuMoon as IconDarkTheme } from 'react-icons/lu';
export { LuSun as IconLightTheme } from 'react-icons/lu';
export { FaRegFolder as IconFolder } from 'react-icons/fa';
export { LuLightbulb as IconHelp } from 'react-icons/lu';
export { LuLightbulbOff as IconHelpOff } from 'react-icons/lu';
export { RiPushpinFill as IconPin } from 'react-icons/ri';
@ -42,13 +45,14 @@ export { BiLastPage as IconPageLast } from 'react-icons/bi';
// ==== User status =======
export { LuUserCircle2 as IconUser } from 'react-icons/lu';
export { FaCircleUser as IconUser2 } from 'react-icons/fa6';
export { LuShovel as IconEditor } from 'react-icons/lu';
export { TbUserEdit as IconEditor } from 'react-icons/tb';
export { LuCrown as IconOwner } from 'react-icons/lu';
export { TbMeteor as IconAdmin } from 'react-icons/tb';
export { TbMeteorOff as IconAdminOff } from 'react-icons/tb';
export { LuGlasses as IconReader } from 'react-icons/lu';
// ===== Domain entities =======
export { TbBriefcase as IconBusiness } from 'react-icons/tb';
export { VscLibrary as IconLibrary } from 'react-icons/vsc';
export { IoLibrary as IconLibrary2 } from 'react-icons/io5';
export { BiDiamond as IconTemplates } from 'react-icons/bi';
@ -56,6 +60,7 @@ export { LuArchive as IconArchive } from 'react-icons/lu';
export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuImage as IconImage } from 'react-icons/lu';
export { TbColumns as IconList } from 'react-icons/tb';
export { ImStack as IconVersions } from 'react-icons/im';
export { TbColumnsOff as IconListOff } from 'react-icons/tb';
export { LuAtSign as IconTerm } from 'react-icons/lu';
export { LuSubscript as IconAlias } from 'react-icons/lu';
@ -64,8 +69,11 @@ export { BiFontFamily as IconText } from 'react-icons/bi';
export { BiFont as IconTextOff } from 'react-icons/bi';
export { RiTreeLine as IconTree } from 'react-icons/ri';
export { FaRegKeyboard as IconControls } from 'react-icons/fa6';
export { BiCheckShield as IconImmutable } from 'react-icons/bi';
export { RiLockLine as IconImmutable } from 'react-icons/ri';
export { RiLockUnlockLine as IconMutable } from 'react-icons/ri';
export { RiOpenSourceLine as IconPublic } from 'react-icons/ri';
export { RiShieldLine as IconProtected } from 'react-icons/ri';
export { RiShieldKeyholeLine as IconPrivate } from 'react-icons/ri';
export { BiBug as IconStatusError } from 'react-icons/bi';
export { BiCheckCircle as IconStatusOK } from 'react-icons/bi';
export { BiHelpCircle as IconStatusUnknown } from 'react-icons/bi';

View File

@ -1,3 +1,5 @@
import clsx from 'clsx';
import TextURL from '@/components/ui/TextURL';
import Tooltip, { PlacesType } from '@/components/ui/Tooltip';
import { useConceptOptions } from '@/context/OptionsContext';
@ -10,19 +12,20 @@ import { CProps } from '../props';
interface BadgeHelpProps extends CProps.Styling {
topic: HelpTopic;
offset?: number;
padding?: string;
place?: PlacesType;
}
function BadgeHelp({ topic, ...restProps }: BadgeHelpProps) {
function BadgeHelp({ topic, padding, ...restProps }: BadgeHelpProps) {
const { showHelp } = useConceptOptions();
if (!showHelp) {
return null;
}
return (
<div tabIndex={-1} id={`help-${topic}`} className='p-1'>
<div tabIndex={-1} id={`help-${topic}`} className={clsx('p-1', padding)}>
<IconHelp size='1.25rem' className='icon-primary' />
<Tooltip clickable anchorSelect={`#help-${topic}`} layer='z-modal-tooltip' {...restProps}>
<Tooltip clickable anchorSelect={`#help-${topic}`} layer='z-modalTooltip' {...restProps}>
<div className='relative' onClick={event => event.stopPropagation()}>
<div className='absolute right-0 text-sm top-[0.4rem] clr-input'>
<TextURL text='Справка...' href={`/manuals?topic=${topic}`} />

View File

@ -9,7 +9,7 @@ interface ConstituentaTooltipProps {
function ConstituentaTooltip({ data, anchor }: ConstituentaTooltipProps) {
return (
<Tooltip clickable layer='z-modal-tooltip' anchorSelect={anchor} className='max-w-[30rem]'>
<Tooltip clickable layer='z-modalTooltip' anchorSelect={anchor} className='max-w-[30rem]'>
<InfoConstituenta data={data} onClick={event => event.stopPropagation()} />
</Tooltip>
);

View File

@ -0,0 +1,60 @@
'use client';
import { useCallback } from 'react';
import Dropdown from '@/components/ui/Dropdown';
import useDropdown from '@/hooks/useDropdown';
import { AccessPolicy } from '@/models/library';
import { prefixes } from '@/utils/constants';
import { describeAccessPolicy, labelAccessPolicy } from '@/utils/labels';
import { PolicyIcon } from '../DomainIcons';
import DropdownButton from '../ui/DropdownButton';
import MiniButton from '../ui/MiniButton';
interface SelectAccessPolicyProps {
value: AccessPolicy;
onChange: (value: AccessPolicy) => void;
disabled?: boolean;
stretchLeft?: boolean;
}
function SelectAccessPolicy({ value, disabled, stretchLeft, onChange }: SelectAccessPolicyProps) {
const menu = useDropdown();
const handleChange = useCallback(
(newValue: AccessPolicy) => {
menu.hide();
if (newValue !== value) {
onChange(newValue);
}
},
[menu, value, onChange]
);
return (
<div ref={menu.ref}>
<MiniButton
title={`Доступ: ${labelAccessPolicy(value)}`}
hideTitle={menu.isOpen}
className='h-full disabled:cursor-auto'
icon={<PolicyIcon value={value} size='1.25rem' />}
onClick={menu.toggle}
disabled={disabled}
/>
<Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft}>
{Object.values(AccessPolicy).map((item, index) => (
<DropdownButton
key={`${prefixes.policy_list}${index}`}
text={labelAccessPolicy(item)}
title={describeAccessPolicy(item)}
icon={<PolicyIcon value={item} size='1rem' />}
onClick={() => handleChange(item)}
/>
))}
</Dropdown>
</div>
);
}
export default SelectAccessPolicy;

View File

@ -1,93 +0,0 @@
'use client';
import { useCallback } from 'react';
import { IconFilter, IconFollow, IconImmutable, IconOwner, IconPublic } from '@/components/Icons';
import Dropdown from '@/components/ui/Dropdown';
import SelectorButton from '@/components/ui/SelectorButton';
import { useAuth } from '@/context/AuthContext';
import useDropdown from '@/hooks/useDropdown';
import useWindowSize from '@/hooks/useWindowSize';
import { LibraryFilterStrategy } from '@/models/miscellaneous';
import { prefixes } from '@/utils/constants';
import { describeLibraryFilter, labelLibraryFilter } from '@/utils/labels';
import DropdownButton from '../ui/DropdownButton';
function StrategyIcon(strategy: LibraryFilterStrategy, size: string, color?: string) {
switch (strategy) {
case LibraryFilterStrategy.MANUAL:
return <IconFilter size={size} className={color} />;
case LibraryFilterStrategy.CANONICAL:
return <IconImmutable size={size} className={color} />;
case LibraryFilterStrategy.COMMON:
return <IconPublic size={size} className={color} />;
case LibraryFilterStrategy.OWNED:
return <IconOwner size={size} className={color} />;
case LibraryFilterStrategy.SUBSCRIBE:
return <IconFollow size={size} className={color} />;
}
}
interface SelectFilterStrategyProps {
value: LibraryFilterStrategy;
onChange: (value: LibraryFilterStrategy) => void;
}
function SelectFilterStrategy({ value, onChange }: SelectFilterStrategyProps) {
const menu = useDropdown();
const { user } = useAuth();
const size = useWindowSize();
const handleChange = useCallback(
(newValue: LibraryFilterStrategy) => {
menu.hide();
onChange(newValue);
},
[menu, onChange]
);
function isStrategyDisabled(strategy: LibraryFilterStrategy): boolean {
if (strategy === LibraryFilterStrategy.SUBSCRIBE || strategy === LibraryFilterStrategy.OWNED) {
return !user;
} else {
return false;
}
}
return (
<div ref={menu.ref} className='h-full text-right'>
<SelectorButton
transparent
tabIndex={-1}
title={describeLibraryFilter(value)}
hideTitle={menu.isOpen}
className='h-full'
icon={StrategyIcon(value, '1rem', value !== LibraryFilterStrategy.MANUAL ? 'icon-primary' : '')}
text={size.isSmall ? undefined : labelLibraryFilter(value)}
onClick={menu.toggle}
/>
<Dropdown isOpen={menu.isOpen}>
{Object.values(LibraryFilterStrategy).map((enumValue, index) => {
const strategy = enumValue as LibraryFilterStrategy;
return (
<DropdownButton
className='w-[10rem]'
key={`${prefixes.library_filters_list}${index}`}
onClick={() => handleChange(strategy)}
title={describeLibraryFilter(strategy)}
disabled={isStrategyDisabled(strategy)}
>
<div className='inline-flex items-center gap-3'>
{StrategyIcon(strategy, '1rem')}
{labelLibraryFilter(strategy)}
</div>
</DropdownButton>
);
})}
</Dropdown>
</div>
);
}
export default SelectFilterStrategy;

View File

@ -10,32 +10,9 @@ import { DependencyMode } from '@/models/miscellaneous';
import { prefixes } from '@/utils/constants';
import { describeCstSource, labelCstSource } from '@/utils/labels';
import {
IconGraphCollapse,
IconGraphExpand,
IconGraphInputs,
IconGraphOutputs,
IconSettings,
IconText
} from '../Icons';
import { DependencyIcon } from '../DomainIcons';
import DropdownButton from '../ui/DropdownButton';
function DependencyIcon(mode: DependencyMode, size: string, color?: string) {
switch (mode) {
case DependencyMode.ALL:
return <IconSettings size={size} className={color} />;
case DependencyMode.EXPRESSION:
return <IconText size={size} className={color} />;
case DependencyMode.OUTPUTS:
return <IconGraphOutputs size={size} className={color} />;
case DependencyMode.INPUTS:
return <IconGraphInputs size={size} className={color} />;
case DependencyMode.EXPAND_OUTPUTS:
return <IconGraphExpand size={size} className={color} />;
case DependencyMode.EXPAND_INPUTS:
return <IconGraphCollapse size={size} className={color} />;
}
}
interface SelectGraphFilterProps {
value: DependencyMode;
onChange: (value: DependencyMode) => void;

View File

@ -0,0 +1,68 @@
'use client';
import { useCallback } from 'react';
import Dropdown from '@/components/ui/Dropdown';
import SelectorButton from '@/components/ui/SelectorButton';
import useDropdown from '@/hooks/useDropdown';
import { LocationHead } from '@/models/library';
import { prefixes } from '@/utils/constants';
import { describeLocationHead, labelLocationHead } from '@/utils/labels';
import { LocationHeadIcon } from '../DomainIcons';
import DropdownButton from '../ui/DropdownButton';
interface SelectLocationHeadProps {
value: LocationHead;
onChange: (value: LocationHead) => void;
excluded?: LocationHead[];
}
function SelectLocationHead({ value, excluded = [], onChange }: SelectLocationHeadProps) {
const menu = useDropdown();
const handleChange = useCallback(
(newValue: LocationHead) => {
menu.hide();
onChange(newValue);
},
[menu, onChange]
);
return (
<div ref={menu.ref} className='h-full text-right'>
<SelectorButton
transparent
tabIndex={-1}
title={describeLocationHead(value)}
hideTitle={menu.isOpen}
className='h-full'
icon={<LocationHeadIcon value={value} size='1rem' />}
text={labelLocationHead(value)}
onClick={menu.toggle}
/>
<Dropdown isOpen={menu.isOpen} className='z-modalTooltip'>
{Object.values(LocationHead)
.filter(head => !excluded.includes(head))
.map((head, index) => {
return (
<DropdownButton
className='w-[10rem]'
key={`${prefixes.location_head_list}${index}`}
onClick={() => handleChange(head)}
title={describeLocationHead(head)}
>
<div className='inline-flex items-center gap-3'>
<LocationHeadIcon value={head} size='1rem' />
{labelLocationHead(head)}
</div>
</DropdownButton>
);
})}
</Dropdown>
</div>
);
}
export default SelectLocationHead;

View File

@ -10,23 +10,9 @@ import { CstMatchMode } from '@/models/miscellaneous';
import { prefixes } from '@/utils/constants';
import { describeCstMatchMode, labelCstMatchMode } from '@/utils/labels';
import { IconAlias, IconFilter, IconFormula, IconTerm, IconText } from '../Icons';
import { MatchModeIcon } from '../DomainIcons';
import DropdownButton from '../ui/DropdownButton';
function MatchModeIcon(mode: CstMatchMode, size: string, color?: string) {
switch (mode) {
case CstMatchMode.ALL:
return <IconFilter size={size} className={color} />;
case CstMatchMode.TEXT:
return <IconText size={size} className={color} />;
case CstMatchMode.EXPR:
return <IconFormula size={size} className={color} />;
case CstMatchMode.TERM:
return <IconTerm size={size} className={color} />;
case CstMatchMode.NAME:
return <IconAlias size={size} className={color} />;
}
}
interface SelectMatchModeProps {
value: CstMatchMode;
onChange: (value: CstMatchMode) => void;

View File

@ -17,7 +17,7 @@ function Dropdown({ isOpen, stretchLeft, className, children, ...restProps }: Dr
<motion.div
tabIndex={-1}
className={clsx(
'z-modal-tooltip',
'z-modalTooltip',
'absolute mt-3',
'flex flex-col',
'border rounded-md shadow-lg',

View File

@ -11,7 +11,7 @@ interface LabeledValueProps extends CProps.Styling {
function LabeledValue({ id, label, text, title, className, ...restProps }: LabeledValueProps) {
return (
<div className={clsx('flex justify-between gap-3', className)} {...restProps}>
<div className={clsx('flex justify-between gap-6', className)} {...restProps}>
<span title={title}>{label}</span>
<span id={id}>{text}</span>
</div>

View File

@ -93,7 +93,7 @@ function Modal({
{children}
</div>
<div className={clsx('z-modal-controls', 'px-6 py-3 flex gap-12 justify-center')}>
<div className={clsx('z-modalControls', 'px-6 py-3 flex gap-12 justify-center')}>
{!readonly ? (
<Button
autoFocus

View File

@ -1,3 +1,5 @@
import clsx from 'clsx';
import { IconSearch } from '../Icons';
import { CProps } from '../props';
import Overlay from './Overlay';
@ -5,23 +7,27 @@ import TextInput from './TextInput';
interface SearchBarProps extends CProps.Styling {
value: string;
noIcon?: boolean;
id?: string;
placeholder?: string;
onChange?: (newValue: string) => void;
noBorder?: boolean;
}
function SearchBar({ id, value, onChange, noBorder, ...restProps }: SearchBarProps) {
function SearchBar({ id, value, noIcon, onChange, noBorder, placeholder = 'Поиск', ...restProps }: SearchBarProps) {
return (
<div {...restProps}>
{!noIcon ? (
<Overlay position='top-[-0.125rem] left-3 translate-y-1/2' className='pointer-events-none clr-text-controls'>
<IconSearch size='1.25rem' />
</Overlay>
) : null}
<TextInput
id={id}
noOutline
placeholder='Поиск'
placeholder={placeholder}
type='search'
className='w-full pl-10 outline-none'
className={clsx('w-full outline-none', !noIcon && 'pl-10')}
noBorder={noBorder}
value={value}
onChange={event => (onChange ? onChange(event.target.value) : undefined)}

View File

@ -101,7 +101,6 @@ function SelectTree<ItemType>({
{foldable.has(item) ? (
<Overlay position='left-[-1.3rem]' className={clsx(!folded.includes(item) && 'top-[0.1rem]')}>
<MiniButton
tabIndex={-1}
noPadding
noHover
icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}

View File

@ -14,9 +14,10 @@ import {
} from '@/app/backendAPI';
import { ErrorData } from '@/components/info/InfoError';
import { ILibraryItem, LibraryItemID } from '@/models/library';
import { matchLibraryItem } from '@/models/libraryAPI';
import { ILibraryCreateData } from '@/models/library';
import { matchLibraryItem, matchLibraryItemLocation } from '@/models/libraryAPI';
import { ILibraryFilter } from '@/models/miscellaneous';
import { IRSForm, IRSFormCloneData, IRSFormCreateData, IRSFormData } from '@/models/rsform';
import { IRSForm, IRSFormCloneData, IRSFormData } from '@/models/rsform';
import { RSFormLoader } from '@/models/RSFormLoader';
import { useAuth } from './AuthContext';
@ -32,7 +33,7 @@ interface ILibraryContext {
applyFilter: (params: ILibraryFilter) => ILibraryItem[];
retrieveTemplate: (templateID: LibraryItemID, callback: (schema: IRSForm) => void) => void;
createItem: (data: IRSFormCreateData, callback?: DataCallback<ILibraryItem>) => void;
createItem: (data: ILibraryCreateData, callback?: DataCallback<ILibraryItem>) => void;
cloneItem: (target: LibraryItemID, data: IRSFormCloneData, callback: DataCallback<IRSFormData>) => void;
destroyItem: (target: LibraryItemID, callback?: () => void) => void;
@ -65,22 +66,28 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
const [cachedTemplates, setCachedTemplates] = useState<IRSForm[]>([]);
const applyFilter = useCallback(
(params: ILibraryFilter) => {
(filter: ILibraryFilter) => {
let result = items;
if (params.is_owned) {
result = result.filter(item => item.owner === user?.id);
if (filter.head) {
result = result.filter(item => item.location.startsWith(filter.head!));
}
if (params.is_common !== undefined) {
result = result.filter(item => item.is_common === params.is_common);
if (filter.isVisible !== undefined) {
result = result.filter(item => filter.isVisible === item.visible);
}
if (params.is_canonical !== undefined) {
result = result.filter(item => item.is_canonical === params.is_canonical);
if (filter.isOwned !== undefined) {
result = result.filter(item => filter.isOwned === (item.owner === user?.id));
}
if (params.is_subscribed !== undefined) {
result = result.filter(item => user?.subscriptions.includes(item.id));
if (filter.isSubscribed !== undefined) {
result = result.filter(item => filter.isSubscribed == user?.subscriptions.includes(item.id));
}
if (params.query) {
result = result.filter(item => matchLibraryItem(item, params.query!));
if (filter.isEditor !== undefined) {
// TODO: load editors from backend
}
if (filter.query) {
result = result.filter(item => matchLibraryItem(item, filter.query!));
}
if (filter.path) {
result = result.filter(item => matchLibraryItemLocation(item, filter.path!));
}
return result;
},
@ -173,7 +180,7 @@ export const LibraryState = ({ children }: LibraryStateProps) => {
);
const createItem = useCallback(
(data: IRSFormCreateData, callback?: DataCallback<ILibraryItem>) => {
(data: ILibraryCreateData, callback?: DataCallback<ILibraryItem>) => {
setError(undefined);
postNewRSForm({
data: data,

View File

@ -18,6 +18,8 @@ import {
patchResetAliases,
patchRestoreOrder,
patchRestoreVersion,
patchSetAccessPolicy,
patchSetLocation,
patchSetOwner,
patchSubstituteConstituents,
patchUploadTRS,
@ -28,7 +30,7 @@ import {
} from '@/app/backendAPI';
import { type ErrorData } from '@/components/info/InfoError';
import useRSFormDetails from '@/hooks/useRSFormDetails';
import { ILibraryItem, IVersionData, VersionID } from '@/models/library';
import { AccessPolicy, ILibraryItem, IVersionData, VersionID } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library';
import {
ConstituentaID,
@ -55,13 +57,13 @@ interface IRSFormContext {
schemaID: string;
versionID?: string;
error: ErrorData;
loading: boolean;
errorLoading: ErrorData;
processing: boolean;
processingError: ErrorData;
isArchive: boolean;
isOwned: boolean;
isClaimable: boolean;
isSubscribed: boolean;
update: (data: ILibraryUpdateData, callback?: DataCallback<ILibraryItem>) => void;
@ -71,6 +73,8 @@ interface IRSFormContext {
subscribe: (callback?: () => void) => void;
unsubscribe: (callback?: () => void) => void;
setOwner: (newOwner: UserID, callback?: () => void) => void;
setAccessPolicy: (newPolicy: AccessPolicy, callback?: () => void) => void;
setLocation: (newLocation: string, callback?: () => void) => void;
setEditors: (newEditors: UserID[], callback?: () => void) => void;
resetAliases: (callback: () => void) => void;
@ -112,8 +116,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
const {
schema, // prettier: split lines
reload,
error,
setError,
error: errorLoading,
setSchema,
loading
} = useRSFormDetails({
@ -121,6 +124,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
version: versionID
});
const [processing, setProcessing] = useState(false);
const [processingError, setProcessingError] = useState<ErrorData>(undefined);
const [toggleTracking, setToggleTracking] = useState(false);
@ -130,10 +134,6 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
const isArchive = useMemo(() => !!versionID, [versionID]);
const isClaimable = useMemo(() => {
return !isArchive && ((user?.id !== schema?.owner && schema?.is_common && !schema?.is_canonical) ?? false);
}, [user, schema?.owner, schema?.is_common, schema?.is_canonical, isArchive]);
const isSubscribed = useMemo(() => {
if (!user || !schema || !user.id) {
return false;
@ -147,12 +147,12 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema) {
return;
}
setError(undefined);
setProcessingError(undefined);
patchLibraryItem(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
library.localUpdateItem(newData);
@ -160,7 +160,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, setSchema, schema, library]
[schemaID, setSchema, schema, library]
);
const upload = useCallback(
@ -168,12 +168,12 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema) {
return;
}
setError(undefined);
setProcessingError(undefined);
patchUploadTRS(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData);
library.localUpdateItem(newData);
@ -181,7 +181,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, setSchema, schema, library]
[schemaID, setSchema, schema, library]
);
const subscribe = useCallback(
@ -189,11 +189,11 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema || !user) {
return;
}
setError(undefined);
setProcessingError(undefined);
postSubscribe(schemaID, {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
if (user.id && !schema.subscribers.includes(user.id)) {
schema.subscribers.push(user.id);
@ -206,7 +206,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, schema, user]
[schemaID, schema, user]
);
const unsubscribe = useCallback(
@ -214,11 +214,11 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema || !user) {
return;
}
setError(undefined);
setProcessingError(undefined);
deleteUnsubscribe(schemaID, {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
if (user.id && schema.subscribers.includes(user.id)) {
schema.subscribers.splice(schema.subscribers.indexOf(user.id), 1);
@ -231,7 +231,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, schema, user]
[schemaID, schema, user]
);
const setOwner = useCallback(
@ -239,21 +239,65 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema) {
return;
}
setError(undefined);
setProcessingError(undefined);
patchSetOwner(schemaID, {
data: {
user: newOwner
},
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
schema.owner = newOwner;
if (callback) callback();
}
});
},
[schemaID, setError, schema]
[schemaID, schema]
);
const setAccessPolicy = useCallback(
(newPolicy: AccessPolicy, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
patchSetAccessPolicy(schemaID, {
data: {
access_policy: newPolicy
},
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: () => {
schema.access_policy = newPolicy;
if (callback) callback();
}
});
},
[schemaID, schema]
);
const setLocation = useCallback(
(newLocation: string, callback?: () => void) => {
if (!schema) {
return;
}
setProcessingError(undefined);
patchSetLocation(schemaID, {
data: {
location: newLocation
},
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: () => {
schema.location = newLocation;
if (callback) callback();
}
});
},
[schemaID, schema]
);
const setEditors = useCallback(
@ -261,21 +305,21 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema) {
return;
}
setError(undefined);
setProcessingError(undefined);
patchSetEditors(schemaID, {
data: {
users: newEditors
},
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
schema.editors = newEditors;
if (callback) callback();
}
});
},
[schemaID, setError, schema]
[schemaID, schema]
);
const resetAliases = useCallback(
@ -283,11 +327,11 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema || !user) {
return;
}
setError(undefined);
setProcessingError(undefined);
patchResetAliases(schemaID, {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
library.localUpdateTimestamp(newData.id);
@ -295,7 +339,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, schema, library, user, setSchema]
[schemaID, schema, library, user, setSchema]
);
const restoreOrder = useCallback(
@ -303,11 +347,11 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
if (!schema || !user) {
return;
}
setError(undefined);
setProcessingError(undefined);
patchRestoreOrder(schemaID, {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
library.localUpdateTimestamp(newData.id);
@ -315,17 +359,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, schema, library, user, setSchema]
[schemaID, schema, library, user, setSchema]
);
const produceStructure = useCallback(
(data: ITargetCst, callback?: DataCallback<ConstituentaID[]>) => {
setError(undefined);
setProcessingError(undefined);
patchProduceStructure(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData.schema);
library.localUpdateTimestamp(newData.schema.id);
@ -333,30 +377,30 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[setError, setSchema, library, schemaID]
[setSchema, library, schemaID]
);
const download = useCallback(
(callback: DataCallback<Blob>) => {
setError(undefined);
setProcessingError(undefined);
getTRSFile(schemaID, String(schema?.version ?? ''), {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: callback
});
},
[schemaID, setError, schema]
[schemaID, schema]
);
const cstCreate = useCallback(
(data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => {
setError(undefined);
setProcessingError(undefined);
postNewConstituenta(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData.schema);
library.localUpdateTimestamp(newData.schema.id);
@ -364,17 +408,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, library, setSchema]
[schemaID, library, setSchema]
);
const cstDelete = useCallback(
(data: IConstituentaList, callback?: () => void) => {
setError(undefined);
setProcessingError(undefined);
patchDeleteConstituenta(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData);
library.localUpdateTimestamp(newData.id);
@ -382,17 +426,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, library, setSchema]
[schemaID, library, setSchema]
);
const cstUpdate = useCallback(
(data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => {
setError(undefined);
setProcessingError(undefined);
patchConstituenta(String(data.id), {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData =>
reload(setProcessing, () => {
library.localUpdateTimestamp(Number(schemaID));
@ -400,17 +444,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
})
});
},
[setError, schemaID, library, reload]
[schemaID, library, reload]
);
const cstRename = useCallback(
(data: ICstRenameData, callback?: DataCallback<IConstituentaMeta>) => {
setError(undefined);
setProcessingError(undefined);
patchRenameConstituenta(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData.schema);
library.localUpdateTimestamp(newData.schema.id);
@ -418,17 +462,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[setError, setSchema, library, schemaID]
[setSchema, library, schemaID]
);
const cstSubstitute = useCallback(
(data: ICstSubstituteData, callback?: () => void) => {
setError(undefined);
setProcessingError(undefined);
patchSubstituteConstituents(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData);
library.localUpdateTimestamp(newData.id);
@ -436,17 +480,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[setError, setSchema, library, schemaID]
[setSchema, library, schemaID]
);
const cstMoveTo = useCallback(
(data: ICstMovetoData, callback?: () => void) => {
setError(undefined);
setProcessingError(undefined);
patchMoveConstituenta(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData);
library.localUpdateTimestamp(Number(schemaID));
@ -454,17 +498,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, library, setSchema]
[schemaID, library, setSchema]
);
const versionCreate = useCallback(
(data: IVersionData, callback?: (version: number) => void) => {
setError(undefined);
setProcessingError(undefined);
postCreateVersion(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData.schema);
library.localUpdateTimestamp(Number(schemaID));
@ -472,17 +516,17 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[schemaID, setError, library, setSchema]
[schemaID, library, setSchema]
);
const versionUpdate = useCallback(
(target: number, data: IVersionData, callback?: () => void) => {
setError(undefined);
setProcessingError(undefined);
patchVersion(String(target), {
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
schema!.versions = schema!.versions.map(prev => {
if (prev.id === target) {
@ -498,16 +542,16 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[setError, schema, setSchema]
[schema, setSchema]
);
const versionDelete = useCallback(
(target: number, callback?: () => void) => {
setError(undefined);
setProcessingError(undefined);
deleteVersion(String(target), {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
schema!.versions = schema!.versions.filter(prev => prev.id !== target);
setSchema(schema);
@ -515,33 +559,33 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[setError, schema, setSchema]
[schema, setSchema]
);
const versionRestore = useCallback(
(target: string, callback?: () => void) => {
setError(undefined);
setProcessingError(undefined);
patchRestoreVersion(target, {
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: () => {
setSchema(schema);
if (callback) callback();
}
});
},
[setError, schema, setSchema]
[schema, setSchema]
);
const inlineSynthesis = useCallback(
(data: IInlineSynthesisData, callback?: DataCallback<IRSFormData>) => {
setError(undefined);
setProcessingError(undefined);
patchInlineSynthesis({
data: data,
showError: true,
setLoading: setProcessing,
onError: setError,
onError: setProcessingError,
onSuccess: newData => {
setSchema(newData);
library.localUpdateTimestamp(Number(schemaID));
@ -549,7 +593,7 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
}
});
},
[setError, library, schemaID, setSchema]
[library, schemaID, setSchema]
);
return (
@ -558,11 +602,11 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
schema,
schemaID,
versionID,
error,
loading,
errorLoading,
processing,
processingError,
isOwned,
isClaimable,
isSubscribed,
isArchive,
update,
@ -577,6 +621,8 @@ export const RSFormState = ({ schemaID, versionID, children }: RSFormStateProps)
unsubscribe,
setOwner,
setEditors,
setAccessPolicy,
setLocation,
cstUpdate,
cstCreate,

View File

@ -0,0 +1,62 @@
'use client';
import clsx from 'clsx';
import { useMemo, useState } from 'react';
import SelectLocationHead from '@/components/select/SelectLocationHead';
import Label from '@/components/ui/Label';
import Modal, { ModalProps } from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import { useAuth } from '@/context/AuthContext';
import { LocationHead } from '@/models/library';
import { combineLocation, validateLocation } from '@/models/libraryAPI';
import { limits } from '@/utils/constants';
interface DlgChangeLocationProps extends Pick<ModalProps, 'hideWindow'> {
initial: string;
onChangeLocation: (newLocation: string) => void;
}
function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeLocationProps) {
const { user } = useAuth();
const [head, setHead] = useState<LocationHead>(initial.substring(0, 2) as LocationHead);
const [body, setBody] = useState<string>(initial.substring(3));
const location = useMemo(() => combineLocation(head, body), [head, body]);
const isValid = useMemo(() => initial !== location && validateLocation(location), [initial, location]);
function handleSubmit() {
onChangeLocation(location);
}
return (
<Modal
header='Изменение расположения'
submitText='Переместить'
submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`}
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className={clsx('w-[35rem]', 'pb-12 px-6 flex gap-3')}
>
<div className='flex flex-col gap-2 w-[7rem] h-min'>
<Label text='Корень' />
<SelectLocationHead
value={head} // prettier: split-lines
onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div>
<TextArea
id='dlg_cst_body'
label='Путь'
className='w-[23rem]'
rows={5}
value={body}
onChange={event => setBody(event.target.value)}
/>
</Modal>
);
}
export default DlgChangeLocation;

View File

@ -5,33 +5,47 @@ import { useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { VisibilityIcon } from '@/components/DomainIcons';
import SelectAccessPolicy from '@/components/select/SelectAccessPolicy';
import SelectLocationHead from '@/components/select/SelectLocationHead';
import Checkbox from '@/components/ui/Checkbox';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal, { ModalProps } from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { ILibraryItem } from '@/models/library';
import { cloneTitle } from '@/models/libraryAPI';
import { AccessPolicy, ILibraryItem, LocationHead } from '@/models/library';
import { cloneTitle, combineLocation, validateLocation } from '@/models/libraryAPI';
import { ConstituentaID, IRSFormCloneData } from '@/models/rsform';
interface DlgCloneLibraryItemProps extends Pick<ModalProps, 'hideWindow'> {
base: ILibraryItem;
initialLocation: string;
selected: ConstituentaID[];
totalCount: number;
}
function DlgCloneLibraryItem({ hideWindow, base, selected, totalCount }: DlgCloneLibraryItemProps) {
function DlgCloneLibraryItem({ hideWindow, base, initialLocation, selected, totalCount }: DlgCloneLibraryItemProps) {
const router = useConceptNavigation();
const { user } = useAuth();
const [title, setTitle] = useState(cloneTitle(base));
const [alias, setAlias] = useState(base.alias);
const [comment, setComment] = useState(base.comment);
const [common, setCommon] = useState(base.is_common);
const [visible, setVisible] = useState(true);
const [policy, setPolicy] = useState(AccessPolicy.PUBLIC);
const [onlySelected, setOnlySelected] = useState(false);
const [head, setHead] = useState(initialLocation.substring(0, 2) as LocationHead);
const [body, setBody] = useState(initialLocation.substring(3));
const location = useMemo(() => combineLocation(head, body), [head, body]);
const { cloneItem } = useLibrary();
const canSubmit = useMemo(() => title !== '' && alias !== '', [title, alias]);
const canSubmit = useMemo(() => title !== '' && alias !== '' && validateLocation(location), [title, alias, location]);
function handleSubmit() {
const data: IRSFormCloneData = {
@ -39,8 +53,10 @@ function DlgCloneLibraryItem({ hideWindow, base, selected, totalCount }: DlgClon
title: title,
alias: alias,
comment: comment,
is_common: common,
is_canonical: false
read_only: false,
visible: visible,
access_policy: policy,
location: location
};
if (onlySelected) {
data.items = selected;
@ -58,7 +74,7 @@ function DlgCloneLibraryItem({ hideWindow, base, selected, totalCount }: DlgClon
canSubmit={canSubmit}
submitText='Создать'
onSubmit={handleSubmit}
className={clsx('px-6 py-2', 'cc-column')}
className={clsx('px-6 py-2', 'cc-column', 'max-h-full w-[30rem]')}
>
<TextInput
id='dlg_full_name'
@ -66,21 +82,60 @@ function DlgCloneLibraryItem({ hideWindow, base, selected, totalCount }: DlgClon
value={title}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex justify-between gap-3'>
<TextInput
id='dlg_alias'
label='Сокращение'
value={alias}
className='max-w-sm'
className='w-[15rem]'
onChange={event => setAlias(event.target.value)}
/>
<div className='flex flex-col gap-2'>
<Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'>
<SelectAccessPolicy
stretchLeft // prettier: split-lines
value={policy}
onChange={newPolicy => setPolicy(newPolicy)}
/>
<MiniButton
className='disabled:cursor-auto'
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={visible} />}
onClick={() => setVisible(prev => !prev)}
/>
</div>
</div>
</div>
<div className='flex justify-between gap-3'>
<div className='flex flex-col gap-2 w-[7rem] h-min'>
<Label text='Корень' />
<SelectLocationHead
value={head}
onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div>
<TextArea
id='dlg_cst_body'
label='Путь'
className='w-[18rem]'
rows={3}
value={body}
onChange={event => setBody(event.target.value)}
/>
</div>
<TextArea id='dlg_comment' label='Описание' value={comment} onChange={event => setComment(event.target.value)} />
<Checkbox
id='dlg_only_selected'
label={`Только выбранные конституенты [${selected.length} из ${totalCount}]`}
value={onlySelected}
setValue={value => setOnlySelected(value)}
/>
<Checkbox id='dlg_is_common' label='Общедоступная схема' value={common} setValue={value => setCommon(value)} />
</Modal>
);
}

View File

@ -12,6 +12,25 @@ export enum LibraryItemType {
OPERATIONS_SCHEMA = 'oss'
}
/**
* Represents Access policy for library items.
*/
export enum AccessPolicy {
PUBLIC = 'public',
PROTECTED = 'protected',
PRIVATE = 'private'
}
/**
* Represents valid location headers.
*/
export enum LocationHead {
USER = '/U',
COMMON = '/S',
LIBRARY = '/L',
PROJECTS = '/P'
}
/**
* Represents {@link LibraryItem} identifier type.
*/
@ -46,8 +65,10 @@ export interface ILibraryItem {
title: string;
alias: string;
comment: string;
is_common: boolean;
is_canonical: boolean;
visible: boolean;
read_only: boolean;
location: string;
access_policy: AccessPolicy;
time_create: string;
time_update: string;
owner: UserID | null;
@ -66,4 +87,27 @@ export interface ILibraryItemEx extends ILibraryItem {
/**
* Represents update data for editing {@link ILibraryItem}.
*/
export interface ILibraryUpdateData extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'id' | 'owner'> {}
export interface ILibraryUpdateData
extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'access_policy' | 'location' | 'id' | 'owner'> {}
/**
* Represents update data for editing {@link AccessPolicy} of a {@link ILibraryItem}.
*/
export interface ITargetAccessPolicy {
access_policy: AccessPolicy;
}
/**
* Represents update data for editing Location of a {@link ILibraryItem}.
*/
export interface ITargetLocation {
location: string;
}
/**
* Represents data, used for creating {@link IRSForm}.
*/
export interface ILibraryCreateData extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'id' | 'owner'> {
file?: File;
fileName?: string;
}

View File

@ -1,5 +1,5 @@
import { ILibraryItem, LibraryItemType } from './library';
import { matchLibraryItem } from './libraryAPI';
import { AccessPolicy, ILibraryItem, LibraryItemType, LocationHead } from './library';
import { matchLibraryItem, validateLocation } from './libraryAPI';
describe('Testing matching LibraryItem', () => {
const item1: ILibraryItem = {
@ -8,11 +8,13 @@ describe('Testing matching LibraryItem', () => {
title: 'Item1',
alias: 'I1',
comment: 'comment',
is_common: true,
is_canonical: true,
time_create: 'I2',
time_update: '',
owner: null
owner: null,
access_policy: AccessPolicy.PUBLIC,
location: LocationHead.COMMON,
read_only: false,
visible: true
};
const itemEmpty: ILibraryItem = {
@ -21,11 +23,13 @@ describe('Testing matching LibraryItem', () => {
title: '',
alias: '',
comment: '',
is_common: true,
is_canonical: true,
time_create: '',
time_update: '',
owner: null
owner: null,
access_policy: AccessPolicy.PUBLIC,
location: LocationHead.COMMON,
read_only: false,
visible: true
};
test('empty input', () => {
@ -43,3 +47,31 @@ describe('Testing matching LibraryItem', () => {
expect(matchLibraryItem(item1, item1.comment)).toEqual(false);
});
});
const validateLocationData = [
['', 'false'],
['U/U', 'false'],
['/A', 'false'],
['/U/user@mail', 'false'],
['U/u\\asdf', 'false'],
['/U/ asdf', 'false'],
['/User', 'false'],
['//', 'false'],
['/S/1 ', 'false'],
['/S/1/2 /3', 'false'],
['/P', 'true'],
['/L', 'true'],
['/U', 'true'],
['/S', 'true'],
['/S/Вася пупки', 'true'],
['/S/123', 'true'],
['/S/1234', 'true'],
['/S/1/!asdf/тест тест', 'true']
];
describe('Testing location validation', () => {
it.each(validateLocationData)('isValid %p', (input: string, expected: string) => {
const result = validateLocation(input);
expect(String(result)).toBe(expected);
});
});

View File

@ -2,10 +2,13 @@
* Module: API for Library entities and Users.
*/
import { limits } from '@/utils/constants';
import { TextMatcher } from '@/utils/utils';
import { ILibraryItem } from './library';
const LOCATION_REGEXP = /^\/[PLUS]((\/[!\d\p{L}]([!\d\p{L} ]*[!\d\p{L}])?)*)?$/u; // cspell:disable-line
/**
* Checks if a given target {@link ILibraryItem} matches the specified query.
*
@ -17,6 +20,17 @@ export function matchLibraryItem(target: ILibraryItem, query: string): boolean {
return matcher.test(target.alias) || matcher.test(target.title);
}
/**
* Checks if a given target {@link ILibraryItem} location matches the specified query.
*
* @param target - item to be matched
* @param query - text to be found
*/
export function matchLibraryItemLocation(target: ILibraryItem, path: string): boolean {
const matcher = new TextMatcher(path);
return matcher.test(target.location);
}
/**
* Generate title for clone {@link ILibraryItem}.
*/
@ -42,3 +56,17 @@ export function nextVersion(version: string): string {
}
return `${version.substring(0, dot)}.${lastNumber + 1}`;
}
/**
* Validation location against regexp.
*/
export function validateLocation(location: string): boolean {
return location.length <= limits.location_len && LOCATION_REGEXP.test(location);
}
/**
* Combining head and body into location.
*/
export function combineLocation(head: string, body?: string): string {
return body ? `${head}/${body}` : head;
}

View File

@ -2,6 +2,8 @@
* Module: Miscellaneous frontend model types. Future targets for refactoring aimed at extracting modules.
*/
import { LocationHead } from './library';
/**
* Represents graph dependency mode.
*/
@ -124,21 +126,13 @@ export enum CstMatchMode {
*/
export interface ILibraryFilter {
query?: string;
is_owned?: boolean;
is_common?: boolean;
is_canonical?: boolean;
is_subscribed?: boolean;
}
path?: string;
head?: LocationHead;
/**
* Represents filtering strategy for Library.
*/
export enum LibraryFilterStrategy {
MANUAL = 'manual',
COMMON = 'common',
SUBSCRIBE = 'subscribe',
CANONICAL = 'canonical',
OWNED = 'owned'
isVisible?: boolean;
isOwned?: boolean;
isSubscribed?: boolean;
isEditor?: boolean;
}
/**

View File

@ -1,7 +1,7 @@
/**
* Module: API for miscellaneous frontend model types. Future targets for refactoring aimed at extracting modules.
*/
import { DependencyMode, FontStyle, GraphSizing, ILibraryFilter, LibraryFilterStrategy } from './miscellaneous';
import { DependencyMode, FontStyle, GraphSizing } from './miscellaneous';
import { IConstituenta, IRSForm } from './rsform';
/**
@ -42,20 +42,6 @@ export function applyGraphFilter(target: IRSForm, start: number, mode: Dependenc
}
}
/**
* Filter list of {@link ILibraryItem} to a given text query.
*/
export function filterFromStrategy(strategy: LibraryFilterStrategy): ILibraryFilter {
// prettier-ignore
switch (strategy) {
case LibraryFilterStrategy.MANUAL: return {};
case LibraryFilterStrategy.COMMON: return { is_common: true };
case LibraryFilterStrategy.CANONICAL: return { is_canonical: true };
case LibraryFilterStrategy.SUBSCRIBE: return { is_subscribed: true };
case LibraryFilterStrategy.OWNED: return { is_owned: true };
}
}
/**
* Apply {@link GraphSizing} to a given {@link IConstituenta}.
*/

View File

@ -4,7 +4,7 @@
import { Graph } from '@/models/Graph';
import { ILibraryItemEx, ILibraryUpdateData, LibraryItemID } from './library';
import { ILibraryItem, ILibraryItemEx, LibraryItemID } from './library';
import { IArgumentInfo, ParsingStatus, ValueClass } from './rslang';
/**
@ -241,18 +241,10 @@ export interface IRSFormData extends ILibraryItemEx {
items: IConstituentaData[];
}
/**
* Represents data, used for creating {@link IRSForm}.
*/
export interface IRSFormCreateData extends ILibraryUpdateData {
file?: File;
fileName?: string;
}
/**
* Represents data, used for cloning {@link IRSForm}.
*/
export interface IRSFormCloneData extends ILibraryUpdateData {
export interface IRSFormCloneData extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'id' | 'owner'> {
items?: ConstituentaID[];
}

View File

@ -2,6 +2,8 @@
* Module: Models for Users.
*/
import { LibraryItemID } from './library';
/**
* Represents {@link User} identifier type.
*/
@ -24,7 +26,7 @@ export interface IUser {
* Represents CurrentUser information.
*/
export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> {
subscriptions: UserID[];
subscriptions: LibraryItemID[];
}
/**

View File

@ -1,147 +0,0 @@
'use client';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { IconDownload } from '@/components/Icons';
import InfoError from '@/components/info/InfoError';
import Button from '@/components/ui/Button';
import Checkbox from '@/components/ui/Checkbox';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import AnimateFade from '@/components/wrap/AnimateFade';
import RequireAuth from '@/components/wrap/RequireAuth';
import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { LibraryItemType } from '@/models/library';
import { IRSFormCreateData } from '@/models/rsform';
import { EXTEOR_TRS_FILE, limits, patterns } from '@/utils/constants';
function CreateRSFormPage() {
const router = useConceptNavigation();
const { createItem, error, setError, processing } = useLibrary();
const [title, setTitle] = useState('');
const [alias, setAlias] = useState('');
const [comment, setComment] = useState('');
const [common, setCommon] = useState(false);
const [fileName, setFileName] = useState('');
const [file, setFile] = useState<File | undefined>();
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setError(undefined);
}, [title, alias, setError]);
function handleCancel() {
if (router.canBack()) {
router.back();
} else {
router.push(urls.library);
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (processing) {
return;
}
const data: IRSFormCreateData = {
item_type: LibraryItemType.RSFORM,
title: title,
alias: alias,
comment: comment,
is_common: common,
is_canonical: false,
file: file,
fileName: file?.name
};
createItem(data, newSchema => {
toast.success('Схема успешно создана');
router.push(urls.schema(newSchema.id));
});
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files && event.target.files.length > 0) {
setFileName(event.target.files[0].name);
setFile(event.target.files[0]);
} else {
setFileName('');
setFile(undefined);
}
}
return (
<AnimateFade>
<RequireAuth>
<form className={clsx('cc-column', 'px-6 py-3')} onSubmit={handleSubmit}>
<h1>Создание концептуальной схемы</h1>
<Overlay position='top-[-2.4rem] right-[-1rem]'>
<input
id='schema_file'
ref={inputRef}
type='file'
style={{ display: 'none' }}
accept={EXTEOR_TRS_FILE}
onChange={handleFileChange}
/>
<MiniButton
title='Загрузить из Экстеор'
icon={<IconDownload size='1.25rem' className='icon-primary' />}
onClick={() => inputRef.current?.click()}
/>
</Overlay>
{fileName ? <Label text={`Загружен файл: ${fileName}`} /> : null}
<TextInput
id='schema_title'
required={!file}
label='Полное название'
placeholder={file && 'Загрузить из файла'}
value={title}
onChange={event => setTitle(event.target.value)}
/>
<TextInput
id='schema_alias'
required={!file}
label='Сокращение'
placeholder={file && 'Загрузить из файла'}
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<TextArea
id='schema_comment'
label='Описание'
placeholder={file && 'Загрузить из файла'}
value={comment}
onChange={event => setComment(event.target.value)}
/>
<Checkbox
id='schema_common'
label='Общедоступная схема'
value={common}
setValue={value => setCommon(value ?? false)}
/>
<div className='flex justify-around gap-6 py-3'>
<SubmitButton text='Создать схему' loading={processing} className='min-w-[10rem]' />
<Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div>
{error ? <InfoError error={error} /> : null}
</form>
</RequireAuth>
</AnimateFade>
);
}
export default CreateRSFormPage;

View File

@ -0,0 +1,16 @@
import AnimateFade from '@/components/wrap/AnimateFade';
import RequireAuth from '@/components/wrap/RequireAuth';
import FormCreateItem from './FormCreateItem';
function CreateItemPage() {
return (
<AnimateFade>
<RequireAuth>
<FormCreateItem />
</RequireAuth>
</AnimateFade>
);
}
export default CreateItemPage;

View File

@ -0,0 +1,190 @@
'use client';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { VisibilityIcon } from '@/components/DomainIcons';
import { IconDownload } from '@/components/Icons';
import InfoError from '@/components/info/InfoError';
import SelectAccessPolicy from '@/components/select/SelectAccessPolicy';
import SelectLocationHead from '@/components/select/SelectLocationHead';
import Button from '@/components/ui/Button';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { ILibraryCreateData } from '@/models/library';
import { combineLocation, validateLocation } from '@/models/libraryAPI';
import { EXTEOR_TRS_FILE, limits, patterns } from '@/utils/constants';
function FormCreateItem() {
const router = useConceptNavigation();
const { user } = useAuth();
const { createItem, error, setError, processing } = useLibrary();
const [title, setTitle] = useState('');
const [alias, setAlias] = useState('');
const [comment, setComment] = useState('');
const [visible, setVisible] = useState(true);
const [policy, setPolicy] = useState(AccessPolicy.PUBLIC);
const [head, setHead] = useState(LocationHead.USER);
const [body, setBody] = useState('');
const location = useMemo(() => combineLocation(head, body), [head, body]);
const isValid = useMemo(() => validateLocation(location), [location]);
const [fileName, setFileName] = useState('');
const [file, setFile] = useState<File | undefined>();
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setError(undefined);
}, [title, alias, setError]);
function handleCancel() {
if (router.canBack()) {
router.back();
} else {
router.push(urls.library);
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (processing) {
return;
}
const data: ILibraryCreateData = {
item_type: LibraryItemType.RSFORM,
title: title,
alias: alias,
comment: comment,
read_only: false,
visible: visible,
access_policy: policy,
location: location,
file: file,
fileName: file?.name
};
createItem(data, newSchema => {
toast.success('Схема успешно создана');
router.push(urls.schema(newSchema.id));
});
}
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files && event.target.files.length > 0) {
setFileName(event.target.files[0].name);
setFile(event.target.files[0]);
} else {
setFileName('');
setFile(undefined);
}
}
return (
<>
<Overlay position='top-[0.5rem] right-[0.5rem]'>
<input
id='schema_file'
ref={inputRef}
type='file'
style={{ display: 'none' }}
accept={EXTEOR_TRS_FILE}
onChange={handleFileChange}
/>
<MiniButton
title='Загрузить из Экстеор'
icon={<IconDownload size='1.25rem' className='icon-primary' />}
onClick={() => inputRef.current?.click()}
/>
</Overlay>
<form className={clsx('cc-column', 'min-w-[30rem]', 'px-6 py-3')} onSubmit={handleSubmit}>
<h1>Создание концептуальной схемы</h1>
{fileName ? <Label text={`Загружен файл: ${fileName}`} /> : null}
<TextInput
id='schema_title'
required={!file}
label='Полное название'
placeholder={file && 'Загрузить из файла'}
value={title}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex justify-between gap-3'>
<TextInput
id='schema_alias'
required={!file}
label='Сокращение'
placeholder={file && 'Загрузить из файла'}
className='w-[14rem]'
pattern={patterns.library_alias}
title={`не более ${limits.library_alias_len} символов`}
value={alias}
onChange={event => setAlias(event.target.value)}
/>
<div className='flex flex-col gap-2'>
<Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'>
<SelectAccessPolicy value={policy} onChange={newPolicy => setPolicy(newPolicy)} />
<MiniButton
className='disabled:cursor-auto'
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={visible} />}
onClick={() => setVisible(prev => !prev)}
/>
</div>
</div>
</div>
<TextArea
id='schema_comment'
label='Описание'
placeholder={file && 'Загрузить из файла'}
value={comment}
onChange={event => setComment(event.target.value)}
/>
<div className='flex justify-between gap-3'>
<div className='flex flex-col gap-2 w-[7rem] h-min'>
<Label text='Корень' />
<SelectLocationHead
value={head}
onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div>
<TextArea
id='dlg_cst_body'
label='Путь'
className='w-[18rem]'
rows={4}
value={body}
onChange={event => setBody(event.target.value)}
/>
</div>
<div className='flex justify-around gap-6 py-3'>
<SubmitButton text='Создать схему' loading={processing} className='min-w-[10rem]' disabled={!isValid} />
<Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div>
{error ? <InfoError error={error} /> : null}
</form>
</>
);
}
export default FormCreateItem;

View File

@ -0,0 +1 @@
export { default } from './CreateItemPage';

View File

@ -1,30 +0,0 @@
import clsx from 'clsx';
import { IconImmutable, IconPublic } from '@/components/Icons';
import { ILibraryItem } from '@/models/library';
import { ICurrentUser } from '@/models/user';
import { prefixes } from '@/utils/constants';
interface ItemIconsProps {
user?: ICurrentUser;
item: ILibraryItem;
}
function ItemIcons({ item }: ItemIconsProps) {
return (
<div className={clsx('min-w-[2.2rem]', 'inline-flex gap-1 align-middle')} id={`${prefixes.library_list}${item.id}`}>
{item.is_common ? (
<span title='Общедоступная'>
<IconPublic size='1rem' />
</span>
) : null}
{item.is_canonical ? (
<span title='Неизменная'>
<IconImmutable size='1rem' />
</span>
) : null}
</div>
);
}
export default ItemIcons;

View File

@ -2,71 +2,78 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { urls } from '@/app/urls';
import DataLoader from '@/components/wrap/DataLoader';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import useLocalStorage from '@/hooks/useLocalStorage';
import useQueryStrings from '@/hooks/useQueryStrings';
import { ILibraryItem } from '@/models/library';
import { ILibraryFilter, LibraryFilterStrategy } from '@/models/miscellaneous';
import { filterFromStrategy } from '@/models/miscellaneousAPI';
import { ILibraryItem, LocationHead } from '@/models/library';
import { ILibraryFilter } from '@/models/miscellaneous';
import { storage } from '@/utils/constants';
import { toggleTristateFlag } from '@/utils/utils';
import SearchPanel from './SearchPanel';
import ViewLibrary from './ViewLibrary';
function LibraryPage() {
const router = useConceptNavigation();
const urlParams = useQueryStrings();
const queryFilter = (urlParams.get('filter') || null) as LibraryFilterStrategy | null;
const { user } = useAuth();
const library = useLibrary();
const [filter, setFilter] = useState<ILibraryFilter>({});
const [items, setItems] = useState<ILibraryItem[]>([]);
const [query, setQuery] = useState('');
const [strategy, setStrategy] = useLocalStorage<LibraryFilterStrategy>(
storage.librarySearchStrategy,
LibraryFilterStrategy.MANUAL
);
const [path, setPath] = useState('');
useLayoutEffect(() => {
if (!queryFilter || !Object.values(LibraryFilterStrategy).includes(queryFilter)) {
router.replace(urls.library_filter(strategy));
return;
}
setQuery('');
setStrategy(queryFilter);
setFilter(filterFromStrategy(queryFilter));
}, [user, router, setQuery, setFilter, setStrategy, strategy, queryFilter]);
const [head, setHead] = useLocalStorage<LocationHead | undefined>(storage.librarySearchHead, undefined);
const [isVisible, setIsVisible] = useLocalStorage<boolean | undefined>(storage.librarySearchVisible, true);
const [isSubscribed, setIsSubscribed] = useLocalStorage<boolean | undefined>(
storage.librarySearchSubscribed,
undefined
);
const [isOwned, setIsOwned] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined);
const [isEditor, setIsEditor] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined);
const filter: ILibraryFilter = useMemo(
() => ({
head: head,
path: path,
query: query,
isEditor: isEditor,
isOwned: isOwned,
isSubscribed: isSubscribed,
isVisible: isVisible
}),
[head, path, query, isEditor, isOwned, isSubscribed, isVisible]
);
useLayoutEffect(() => {
setItems(library.applyFilter(filter));
}, [library, filter, filter.query]);
const resetQuery = useCallback(() => {
const toggleVisible = useCallback(() => setIsVisible(prev => toggleTristateFlag(prev)), [setIsVisible]);
const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]);
const toggleSubscribed = useCallback(() => setIsSubscribed(prev => toggleTristateFlag(prev)), [setIsSubscribed]);
const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]);
const resetFilter = useCallback(() => {
setQuery('');
setFilter({});
}, []);
setPath('');
setHead(undefined);
setIsVisible(true);
setIsSubscribed(undefined);
setIsOwned(undefined);
setIsEditor(undefined);
}, [setHead, setIsVisible, setIsSubscribed, setIsOwned, setIsEditor]);
const view = useMemo(
() => (
<ViewLibrary
resetQuery={resetQuery} //
resetQuery={resetFilter} // prettier: split lines
items={items}
/>
),
[resetQuery, items]
[resetFilter, items]
);
return (
<DataLoader
id='library-page' //
id='library-page' // prettier: split lines
isLoading={library.loading}
error={library.error}
hasNoData={library.items.length === 0}
@ -74,10 +81,20 @@ function LibraryPage() {
<SearchPanel
query={query}
setQuery={setQuery}
strategy={strategy}
path={path}
setPath={setPath}
head={head}
setHead={setHead}
total={library.items.length ?? 0}
filtered={items.length}
setFilter={setFilter}
isVisible={isVisible}
isOwned={isOwned}
toggleOwned={toggleOwned}
toggleVisible={toggleVisible}
isSubscribed={isSubscribed}
toggleSubscribed={toggleSubscribed}
isEditor={isEditor}
toggleEditor={toggleEditor}
/>
{view}
</DataLoader>

View File

@ -1,51 +1,71 @@
'use client';
import clsx from 'clsx';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { urls } from '@/app/urls';
import { LocationHeadIcon, SubscribeIcon, VisibilityIcon } from '@/components/DomainIcons';
import { IconEditor, IconFolder, IconOwner } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton';
import MiniButton from '@/components/ui/MiniButton';
import SearchBar from '@/components/ui/SearchBar';
import { useConceptNavigation } from '@/context/NavigationContext';
import { ILibraryFilter } from '@/models/miscellaneous';
import { LibraryFilterStrategy } from '@/models/miscellaneous';
import SelectFilterStrategy from '../../components/select/SelectFilterStrategy';
import SelectorButton from '@/components/ui/SelectorButton';
import useDropdown from '@/hooks/useDropdown';
import { LocationHead } from '@/models/library';
import { HelpTopic } from '@/models/miscellaneous';
import { prefixes } from '@/utils/constants';
import { describeLocationHead, labelLocationHead } from '@/utils/labels';
import { tripleToggleColor } from '@/utils/utils';
interface SearchPanelProps {
total: number;
filtered: number;
setFilter: React.Dispatch<React.SetStateAction<ILibraryFilter>>;
query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>;
strategy: LibraryFilterStrategy;
path: string;
setPath: React.Dispatch<React.SetStateAction<string>>;
head: LocationHead | undefined;
setHead: React.Dispatch<React.SetStateAction<LocationHead | undefined>>;
isVisible: boolean | undefined;
toggleVisible: () => void;
isOwned: boolean | undefined;
toggleOwned: () => void;
isSubscribed: boolean | undefined;
toggleSubscribed: () => void;
isEditor: boolean | undefined;
toggleEditor: () => void;
}
function SearchPanel({ total, filtered, query, setQuery, strategy, setFilter }: SearchPanelProps) {
const router = useConceptNavigation();
function SearchPanel({
total,
filtered,
query,
setQuery,
path,
setPath,
head,
setHead,
function handleChangeQuery(newQuery: string) {
setQuery(newQuery);
setFilter(prev => ({
query: newQuery,
is_owned: prev.is_owned,
is_common: prev.is_common,
is_canonical: prev.is_canonical,
is_subscribed: prev.is_subscribed
}));
}
isVisible,
toggleVisible,
isOwned,
toggleOwned,
isSubscribed,
toggleSubscribed,
isEditor,
toggleEditor
}: SearchPanelProps) {
const headMenu = useDropdown();
const handleChangeStrategy = useCallback(
(value: LibraryFilterStrategy) => {
if (value !== strategy) {
router.push(urls.library_filter(value));
}
const handleChange = useCallback(
(newValue: LocationHead | undefined) => {
headMenu.hide();
setHead(newValue);
},
[strategy, router]
);
const selectStrategy = useMemo(
() => <SelectFilterStrategy value={strategy} onChange={handleChangeStrategy} />,
[strategy, handleChangeStrategy]
[headMenu, setHead]
);
return (
@ -53,33 +73,107 @@ function SearchPanel({ total, filtered, query, setQuery, strategy, setFilter }:
className={clsx(
'sticky top-0', // prettier: split lines
'w-full h-[2.2rem]',
'sm:pr-[12rem] flex',
'pr-3 flex items-center',
'border-b',
'text-sm',
'clr-input'
)}
>
<div
className={clsx(
'min-w-[10rem]', // prettier: split lines
'px-2 self-center',
'select-none',
'whitespace-nowrap'
)}
>
<div className={clsx('px-2 self-center', 'min-w-[9rem]', 'select-none', 'whitespace-nowrap')}>
Фильтр
<span className='ml-2'>
{filtered} из {total}
</span>
</div>
{selectStrategy}
<div className='cc-icons'>
<MiniButton
title='Видимость'
icon={<VisibilityIcon value={true} className={tripleToggleColor(isVisible)} />}
onClick={toggleVisible}
/>
<MiniButton
title='Я - Подписчик'
icon={<SubscribeIcon value={true} className={tripleToggleColor(isSubscribed)} />}
onClick={toggleSubscribed}
/>
<MiniButton
title='Я - Владелец'
icon={<IconOwner size='1.25rem' className={tripleToggleColor(isOwned)} />}
onClick={toggleOwned}
/>
<MiniButton
title='Я - Редактор'
icon={<IconEditor size='1.25rem' className={tripleToggleColor(isEditor)} />}
onClick={toggleEditor}
/>
</div>
<div className='flex items-center h-full mx-auto'>
<SearchBar
id='library_search'
placeholder='Аттрибуты'
noBorder
className='mx-auto min-w-[10rem]'
className='min-w-[10rem]'
value={query}
onChange={handleChangeQuery}
onChange={setQuery}
/>
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'>
<SelectorButton
transparent
className='h-full rounded-lg'
title={head ? describeLocationHead(head) : 'Выберите каталог'}
hideTitle={headMenu.isOpen}
icon={
head ? (
<LocationHeadIcon value={head} size='1.25rem' />
) : (
<IconFolder size='1.25rem' className='clr-text-controls' />
)
}
onClick={headMenu.toggle}
text={head ?? '//'}
/>
<Dropdown isOpen={headMenu.isOpen} className='z-modalTooltip'>
<DropdownButton className='w-[10rem]' onClick={() => handleChange(undefined)}>
<div className='inline-flex items-center gap-3'>
<IconFolder size='1rem' className='clr-text-controls' />
<span>отображать все</span>
</div>
</DropdownButton>
{Object.values(LocationHead).map((head, index) => {
return (
<DropdownButton
className='w-[10rem]'
key={`${prefixes.location_head_list}${index}`}
onClick={() => handleChange(head)}
title={describeLocationHead(head)}
>
<div className='inline-flex items-center gap-3'>
<LocationHeadIcon value={head} size='1rem' />
{labelLocationHead(head)}
</div>
</DropdownButton>
);
})}
</Dropdown>
</div>
<SearchBar
id='path_search'
placeholder='Путь'
noIcon
noBorder
className='min-w-[10rem]'
value={path}
onChange={setPath}
/>
</div>
<BadgeHelp topic={HelpTopic.UI_LIBRARY} className='max-w-[30rem] text-sm' offset={5} place='right-start' />
</div>
);
}

View File

@ -1,11 +1,9 @@
'use client';
import clsx from 'clsx';
import { useLayoutEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { urls } from '@/app/urls';
import BadgeHelp from '@/components/info/BadgeHelp';
import { CProps } from '@/components/props';
import DataTable, { createColumnHelper, VisibilityState } from '@/components/ui/DataTable';
import FlexColumn from '@/components/ui/FlexColumn';
@ -16,11 +14,8 @@ import { useUsers } from '@/context/UsersContext';
import useLocalStorage from '@/hooks/useLocalStorage';
import useWindowSize from '@/hooks/useWindowSize';
import { ILibraryItem } from '@/models/library';
import { HelpTopic } from '@/models/miscellaneous';
import { storage } from '@/utils/constants';
import ItemIcons from './ItemIcons';
interface ViewLibraryProps {
items: ILibraryItem[];
resetQuery: () => void;
@ -51,14 +46,6 @@ function ViewLibrary({ items, resetQuery }: ViewLibraryProps) {
const columns = useMemo(
() => [
columnHelper.display({
id: 'status',
header: '',
size: 60,
minSize: 60,
maxSize: 60,
cell: props => <ItemIcons item={props.row.original} />
}),
columnHelper.accessor('alias', {
id: 'alias',
header: 'Шифр',
@ -66,6 +53,7 @@ function ViewLibrary({ items, resetQuery }: ViewLibraryProps) {
minSize: 80,
maxSize: 150,
enableSorting: true,
cell: props => <div className='pl-2'>{props.getValue()}</div>,
sortingFn: 'text'
}),
columnHelper.accessor('title', {
@ -114,19 +102,6 @@ function ViewLibrary({ items, resetQuery }: ViewLibraryProps) {
const tableHeight = useMemo(() => calculateHeight('2.2rem'), [calculateHeight]);
return (
<>
<div className='sticky top-[2.3rem]'>
<div
className={clsx(
'z-pop', // prettier: split lines
'absolute top-[0.125rem] left-[0.25rem]',
'ml-3',
'flex gap-1'
)}
>
<BadgeHelp topic={HelpTopic.UI_LIBRARY} className='max-w-[30rem] text-sm' offset={5} place='right-start' />
</div>
</div>
<DataTable
id='library_data'
columns={columns}
@ -155,7 +130,6 @@ function ViewLibrary({ items, resetQuery }: ViewLibraryProps) {
onChangePaginationOption={setItemsPerPage}
paginationOptions={[10, 20, 30, 50, 100]}
/>
</>
);
}

View File

@ -37,7 +37,7 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
className={clsx(
'absolute left-0 w-[13.5rem]', // prettier: split-lines
'flex flex-col',
'z-modal-tooltip',
'z-modalTooltip',
'text-xs sm:text-sm',
'select-none',
{

View File

@ -1,4 +1,4 @@
import { IconEditor, IconList, IconNewItem, IconShare, IconUpload } from '@/components/Icons';
import { IconEditor, IconNewItem, IconShare, IconUpload, IconVersions } from '@/components/Icons';
function HelpVersions() {
return (
@ -23,7 +23,7 @@ function HelpVersions() {
</li>
<li>
<IconList size='1.25rem' className='inline-icon' /> Редактировать атрибуты версий
<IconVersions size='1.25rem' className='inline-icon' /> Редактировать атрибуты версий
</li>
</div>
);

View File

@ -0,0 +1,70 @@
import { useMemo } from 'react';
import { VisibilityIcon } from '@/components/DomainIcons';
import { IconImmutable, IconMutable } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import SelectAccessPolicy from '@/components/select/SelectAccessPolicy';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import { useAccessMode } from '@/context/AccessModeContext';
import { AccessPolicy } from '@/models/library';
import { HelpTopic } from '@/models/miscellaneous';
import { UserLevel } from '@/models/user';
import { useRSEdit } from '../RSEditContext';
interface AccessToolbarProps {
visible: boolean;
toggleVisible: () => void;
readOnly: boolean;
toggleReadOnly: () => void;
}
function AccessToolbar({ visible, toggleVisible, readOnly, toggleReadOnly }: AccessToolbarProps) {
const controller = useRSEdit();
const { accessLevel } = useAccessMode();
const policy = useMemo(
() => controller.schema?.access_policy ?? AccessPolicy.PRIVATE,
[controller.schema?.access_policy]
);
return (
<Overlay position='top-[4.5rem] right-0 w-[12rem] pr-2' className='flex'>
<Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'>
<SelectAccessPolicy
disabled={accessLevel <= UserLevel.EDITOR || controller.isProcessing}
value={policy}
onChange={newPolicy => controller.setAccessPolicy(newPolicy)}
/>
<MiniButton
className='disabled:cursor-auto'
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={visible} />}
onClick={toggleVisible}
disabled={accessLevel === UserLevel.READER || controller.isProcessing}
/>
<MiniButton
className='disabled:cursor-auto'
title={readOnly ? 'Изменение: запрещено' : 'Изменение: разрешено'}
icon={
readOnly ? (
<IconImmutable size='1.25rem' className='clr-text-primary' />
) : (
<IconMutable size='1.25rem' className='clr-text-green' />
)
}
onClick={toggleReadOnly}
disabled={accessLevel === UserLevel.READER || controller.isProcessing}
/>
<BadgeHelp topic={HelpTopic.VERSIONS} className='max-w-[30rem]' offset={4} />
</div>
</Overlay>
);
}
export default AccessToolbar;

View File

@ -50,7 +50,20 @@ function EditorLibraryItem({ item, isModified }: EditorLibraryItemProps) {
return (
<div className='flex flex-col'>
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[6rem] cc-icons'>
<Overlay position='top-[-0.5rem] left-[2.3rem] cc-icons'>
<MiniButton
title='Изменить путь'
noHover
onClick={() => controller.promptLocation()}
icon={<IconEdit size='1rem' className='mt-1 icon-primary' />}
disabled={isModified || controller.isProcessing}
/>
</Overlay>
) : null}
<LabeledValue className='sm:mb-1 text-ellipsis max-w-[30rem]' label='Путь' text={item?.location ?? ''} />
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title='Изменить владельца'
@ -61,7 +74,7 @@ function EditorLibraryItem({ item, isModified }: EditorLibraryItemProps) {
/>
{ownerSelector.isOpen ? (
<SelectUser
className='w-[20rem] sm:w-[22.5rem] text-sm'
className='w-[21rem] sm:w-[23rem] text-sm'
items={users}
value={item?.owner ?? undefined}
onSelectValue={onSelectUser}
@ -73,7 +86,7 @@ function EditorLibraryItem({ item, isModified }: EditorLibraryItemProps) {
<LabeledValue className='sm:mb-1' label='Владелец' text={getUserLabel(item?.owner ?? null)} />
{accessLevel >= UserLevel.OWNER ? (
<Overlay position='top-[-0.5rem] left-[6rem] cc-icons'>
<Overlay position='top-[-0.5rem] left-[5.5rem] cc-icons'>
<div className='flex items-start'>
<MiniButton
title='Изменить редакторов'
@ -86,12 +99,12 @@ function EditorLibraryItem({ item, isModified }: EditorLibraryItemProps) {
</Overlay>
) : null}
<LabeledValue id='editor_stats' className='sm:mb-1' label='Редакторы' text={item?.editors.length ?? 0} />
<Tooltip anchorSelect='#editor_stats' layer='z-modal-tooltip'>
<Tooltip anchorSelect='#editor_stats' layer='z-modalTooltip'>
<InfoUsers items={item?.editors ?? []} prefix={prefixes.user_editors} />
</Tooltip>
<LabeledValue id='sub_stats' className='sm:mb-1' label='Отслеживают' text={item?.subscribers.length ?? 0} />
<Tooltip anchorSelect='#sub_stats' layer='z-modal-tooltip'>
<Tooltip anchorSelect='#sub_stats' layer='z-modalTooltip'>
<InfoUsers items={item?.subscribers ?? []} prefix={prefixes.user_subs} />
</Tooltip>

View File

@ -21,7 +21,7 @@ interface EditorRSFormProps {
}
function EditorRSForm({ isModified, onDestroy, setIsModified }: EditorRSFormProps) {
const { schema, isClaimable, isSubscribed } = useRSForm();
const { schema, isSubscribed } = useRSForm();
const { user } = useAuth();
function initiateSubmit() {
@ -45,13 +45,12 @@ function EditorRSForm({ isModified, onDestroy, setIsModified }: EditorRSFormProp
<RSFormToolbar
subscribed={isSubscribed}
modified={isModified}
claimable={isClaimable}
anonymous={!user}
onSubmit={initiateSubmit}
onDestroy={onDestroy}
/>
<AnimateFade onKeyDown={handleInput} className={clsx('sm:w-fit w-full', 'flex flex-col sm:flex-row')}>
<FlexColumn className='px-4 pb-2'>
<AnimateFade onKeyDown={handleInput} className={clsx('sm:w-fit mx-auto', 'flex flex-col sm:flex-row')}>
<FlexColumn className='px-3'>
<FormRSForm id={globals.library_item_editor} isModified={isModified} setIsModified={setIsModified} />
<Divider margins='my-1' />

View File

@ -4,24 +4,19 @@ import clsx from 'clsx';
import { useEffect, useLayoutEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { IconList, IconNewItem, IconSave, IconUpload } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import { IconSave } from '@/components/Icons';
import SelectVersion from '@/components/select/SelectVersion';
import Checkbox from '@/components/ui/Checkbox';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useRSForm } from '@/context/RSFormContext';
import { LibraryItemType } from '@/models/library';
import { HelpTopic } from '@/models/miscellaneous';
import { IRSFormCreateData } from '@/models/rsform';
import { ILibraryUpdateData, LibraryItemType } from '@/models/library';
import { limits, patterns } from '@/utils/constants';
import { useRSEdit } from '../RSEditContext';
import AccessToolbar from './AccessToolbar';
import VersionsToolbar from './VersionsToolbar';
interface FormRSFormProps {
id?: string;
@ -31,14 +26,13 @@ interface FormRSFormProps {
function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
const { schema, update, processing } = useRSForm();
const { user } = useAuth();
const controller = useRSEdit();
const [title, setTitle] = useState('');
const [alias, setAlias] = useState('');
const [comment, setComment] = useState('');
const [common, setCommon] = useState(false);
const [canonical, setCanonical] = useState(false);
const [visible, setVisible] = useState(false);
const [readOnly, setReadOnly] = useState(false);
useEffect(() => {
if (!schema) {
@ -49,8 +43,8 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
schema.title !== title ||
schema.alias !== alias ||
schema.comment !== comment ||
schema.is_common !== common ||
schema.is_canonical !== canonical
schema.visible !== visible ||
schema.read_only !== readOnly
);
return () => setIsModified(false);
}, [
@ -58,13 +52,13 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
schema?.title,
schema?.alias,
schema?.comment,
schema?.is_common,
schema?.is_canonical,
schema?.visible,
schema?.read_only,
title,
alias,
comment,
common,
canonical,
visible,
readOnly,
setIsModified
]);
@ -73,8 +67,8 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
setTitle(schema.title);
setAlias(schema.alias);
setComment(schema.comment);
setCommon(schema.is_common);
setCanonical(schema.is_canonical);
setVisible(schema.visible);
setReadOnly(schema.read_only);
}
}, [schema]);
@ -82,28 +76,29 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
if (event) {
event.preventDefault();
}
const data: IRSFormCreateData = {
const data: ILibraryUpdateData = {
item_type: LibraryItemType.RSFORM,
title: title,
alias: alias,
comment: comment,
is_common: common,
is_canonical: canonical
visible: visible,
read_only: readOnly
};
update(data, () => toast.success('Изменения сохранены'));
};
return (
<form id={id} className={clsx('cc-column', 'mt-1 min-w-[22rem] sm:w-[30rem]', 'py-1')} onSubmit={handleSubmit}>
<form id={id} className={clsx('mt-1 min-w-[22rem] sm:w-[30rem]', 'flex flex-col pt-1')} onSubmit={handleSubmit}>
<TextInput
id='schema_title'
required
label='Полное название'
className='mb-3'
value={title}
disabled={!controller.isContentEditable}
onChange={event => setTitle(event.target.value)}
/>
<div className='flex justify-between w-full gap-3'>
<div className='flex justify-between w-full gap-3 mb-3'>
<TextInput
id='schema_alias'
required
@ -116,40 +111,24 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
onChange={event => setAlias(event.target.value)}
/>
<div className='flex flex-col'>
<Overlay position='top-[-0.25rem] right-[-0.25rem] cc-icons'>
{controller.isMutable ? (
<>
<MiniButton
title={!controller.isContentEditable ? 'Откатить к версии' : 'Переключитесь на неактуальную версию'}
disabled={controller.isContentEditable}
onClick={() => controller.restoreVersion()}
icon={<IconUpload size='1.25rem' className='icon-red' />}
<VersionsToolbar />
<AccessToolbar
visible={visible}
toggleVisible={() => setVisible(prev => !prev)}
readOnly={readOnly}
toggleReadOnly={() => setReadOnly(prev => !prev)}
/>
<MiniButton
title={controller.isContentEditable ? 'Создать версию' : 'Переключитесь на актуальную версию'}
disabled={!controller.isContentEditable}
onClick={controller.createVersion}
icon={<IconNewItem size='1.25rem' className='icon-green' />}
/>
<MiniButton
title={schema?.versions.length === 0 ? 'Список версий пуст' : 'Редактировать версии'}
disabled={!schema || schema?.versions.length === 0}
onClick={controller.editVersions}
icon={<IconList size='1.25rem' className='icon-primary' />}
/>
</>
) : null}
<BadgeHelp topic={HelpTopic.VERSIONS} className='max-w-[30rem]' offset={4} />
</Overlay>
<Label text='Версия' className='mb-2' />
<SelectVersion
id='schema_version'
className='select-none'
value={schema?.version} // prettier: split lines
items={schema?.versions}
onSelectValue={controller.viewVersion}
/>
</div>
</div>
<TextArea
id='schema_comment'
label='Описание'
@ -158,28 +137,10 @@ function FormRSForm({ id, isModified, setIsModified }: FormRSFormProps) {
disabled={!controller.isContentEditable || controller.isProcessing}
onChange={event => setComment(event.target.value)}
/>
<div className='flex justify-between whitespace-nowrap'>
<Checkbox
id='schema_common'
label='Общедоступная схема'
title='Общедоступные схемы видны всем пользователям и могут быть изменены'
disabled={!controller.isContentEditable || controller.isProcessing}
value={common}
setValue={value => setCommon(value)}
/>
<Checkbox
id='schema_immutable'
label='Неизменная схема'
title='Только администраторы могут присваивать схемам неизменный статус'
disabled={!controller.isContentEditable || !user?.is_staff || controller.isProcessing}
value={canonical}
setValue={value => setCanonical(value)}
/>
</div>
{controller.isContentEditable ? (
{controller.isContentEditable || isModified ? (
<SubmitButton
text='Сохранить изменения'
className='self-center'
className='self-center mt-4'
loading={processing}
disabled={!isModified}
icon={<IconSave size='1.25rem' />}

View File

@ -2,7 +2,8 @@
import { useMemo } from 'react';
import { IconDestroy, IconDownload, IconFollow, IconFollowOff, IconSave, IconShare } from '@/components/Icons';
import { SubscribeIcon } from '@/components/DomainIcons';
import { IconDestroy, IconDownload, IconSave, IconShare } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
@ -17,7 +18,6 @@ interface RSFormToolbarProps {
modified: boolean;
subscribed: boolean;
anonymous: boolean;
claimable: boolean;
onSubmit: () => void;
onDestroy: () => void;
}
@ -28,7 +28,7 @@ function RSFormToolbar({ modified, anonymous, subscribed, onSubmit, onDestroy }:
const canSave = useMemo(() => modified && !controller.isProcessing, [modified, controller.isProcessing]);
return (
<Overlay position='top-1 right-1/2 translate-x-1/2' className='cc-icons'>
{controller.isContentEditable ? (
{controller.isContentEditable || modified ? (
<MiniButton
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}
disabled={!canSave}
@ -49,13 +49,7 @@ function RSFormToolbar({ modified, anonymous, subscribed, onSubmit, onDestroy }:
{!anonymous ? (
<MiniButton
titleHtml={`Отслеживание <b>${subscribed ? 'включено' : 'выключено'}</b>`}
icon={
subscribed ? (
<IconFollow size='1.25rem' className='icon-primary' />
) : (
<IconFollowOff size='1.25rem' className='clr-text-controls' />
)
}
icon={<SubscribeIcon value={subscribed} className={subscribed ? 'icon-primary' : 'clr-text-controls'} />}
disabled={controller.isProcessing}
onClick={controller.toggleSubscribe}
/>

View File

@ -0,0 +1,40 @@
import { IconNewItem, IconUpload, IconVersions } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import { HelpTopic } from '@/models/miscellaneous';
import { useRSEdit } from '../RSEditContext';
function VersionsToolbar() {
const controller = useRSEdit();
return (
<Overlay position='top-[-0.4rem] right-[0rem]' className='cc-icons pr-2'>
{controller.isMutable ? (
<>
<MiniButton
title={!controller.isContentEditable ? 'Откатить к версии' : 'Переключитесь на неактуальную версию'}
disabled={controller.isContentEditable}
onClick={() => controller.restoreVersion()}
icon={<IconUpload size='1.25rem' className='icon-red' />}
/>
<MiniButton
title={controller.isContentEditable ? 'Создать версию' : 'Переключитесь на актуальную версию'}
disabled={!controller.isContentEditable}
onClick={controller.createVersion}
icon={<IconNewItem size='1.25rem' className='icon-green' />}
/>
<MiniButton
title={controller.schema?.versions.length === 0 ? 'Список версий пуст' : 'Редактировать версии'}
disabled={!controller.schema || controller.schema?.versions.length === 0}
onClick={controller.editVersions}
icon={<IconVersions size='1.25rem' className='icon-primary' />}
/>
</>
) : null}
<BadgeHelp topic={HelpTopic.VERSIONS} className='max-w-[30rem]' offset={4} />
</Overlay>
);
}
export default VersionsToolbar;

View File

@ -105,7 +105,7 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
return (
<>
{controller.isContentEditable ? <RSListToolbar /> : null}
<AnimateFade tabIndex={-1} className='outline-none' onKeyDown={handleKeyDown}>
<AnimateFade tabIndex={-1} onKeyDown={handleKeyDown}>
{controller.isContentEditable ? (
<SelectedCounter
totalCount={controller.schema?.stats?.count_all ?? 0}

View File

@ -16,6 +16,7 @@ import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useConceptOptions } from '@/context/OptionsContext';
import { useRSForm } from '@/context/RSFormContext';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCloneLibraryItem from '@/dialogs/DlgCloneLibraryItem';
import DlgConstituentaTemplate from '@/dialogs/DlgConstituentaTemplate';
import DlgCreateCst from '@/dialogs/DlgCreateCst';
@ -28,7 +29,7 @@ import DlgInlineSynthesis from '@/dialogs/DlgInlineSynthesis';
import DlgRenameCst from '@/dialogs/DlgRenameCst';
import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import { IVersionData, VersionID } from '@/models/library';
import { AccessPolicy, IVersionData, LocationHead, VersionID } from '@/models/library';
import {
ConstituentaID,
CstType,
@ -60,7 +61,9 @@ interface IRSEditContext {
nothingSelected: boolean;
setOwner: (newOwner: UserID) => void;
setAccessPolicy: (newPolicy: AccessPolicy) => void;
promptEditors: () => void;
promptLocation: () => void;
toggleSubscribe: () => void;
setSelected: React.Dispatch<React.SetStateAction<ConstituentaID[]>>;
@ -130,7 +133,10 @@ export const RSEditState = ({
const { accessLevel, setAccessLevel } = useAccessMode();
const model = useRSForm();
const isMutable = useMemo(() => accessLevel > UserLevel.READER, [accessLevel]);
const isMutable = useMemo(
() => accessLevel > UserLevel.READER && !model.schema?.read_only,
[accessLevel, model.schema?.read_only]
);
const isContentEditable = useMemo(() => isMutable && !model.isArchive, [isMutable, model.isArchive]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]);
@ -138,6 +144,7 @@ export const RSEditState = ({
const [showClone, setShowClone] = useState(false);
const [showDeleteCst, setShowDeleteCst] = useState(false);
const [showEditEditors, setShowEditEditors] = useState(false);
const [showEditLocation, setShowEditLocation] = useState(false);
const [showEditTerm, setShowEditTerm] = useState(false);
const [showSubstitute, setShowSubstitute] = useState(false);
const [showCreateVersion, setShowCreateVersion] = useState(false);
@ -199,6 +206,21 @@ export const RSEditState = ({
});
}, [model, viewVersion]);
function calculateCloneLocation(): string {
if (!model.schema) {
return LocationHead.USER;
}
const location = model.schema.location;
const head = model.schema.location.substring(0, 2) as LocationHead;
if (head === LocationHead.LIBRARY) {
return user?.is_staff ? location : LocationHead.USER;
}
if (model.schema.owner === user?.id) {
return location;
}
return head === LocationHead.USER ? LocationHead.USER : location;
}
const handleCreateCst = useCallback(
(data: ICstCreateData) => {
if (!model.schema) {
@ -302,6 +324,16 @@ export const RSEditState = ({
[model]
);
const handleSetLocation = useCallback(
(newLocation: string) => {
if (!model.schema) {
return;
}
model.setLocation(newLocation, () => toast.success('Схема перемещена'));
},
[model]
);
const handleInlineSynthesis = useCallback(
(data: IInlineSynthesisData) => {
if (!model.schema) {
@ -485,6 +517,10 @@ export const RSEditState = ({
setShowEditEditors(true);
}, []);
const promptLocation = useCallback(() => {
setShowEditLocation(true);
}, []);
const download = useCallback(() => {
if (isModified && !promptUnsaved()) {
return;
@ -523,6 +559,13 @@ export const RSEditState = ({
[model]
);
const setAccessPolicy = useCallback(
(newPolicy: AccessPolicy) => {
model.setAccessPolicy(newPolicy, () => toast.success('Политика доступа изменена'));
},
[model]
);
const setEditors = useCallback(
(newEditors: UserID[]) => {
model.setEditors(newEditors, () => toast.success('Редакторы обновлены'));
@ -543,7 +586,9 @@ export const RSEditState = ({
toggleSubscribe,
setOwner,
setAccessPolicy,
promptEditors,
promptLocation,
setSelected: setSelected,
select: (target: ConstituentaID) => setSelected(prev => [...prev, target]),
@ -584,6 +629,7 @@ export const RSEditState = ({
{showClone ? (
<DlgCloneLibraryItem
base={model.schema}
initialLocation={calculateCloneLocation()}
hideWindow={() => setShowClone(false)}
selected={selected}
totalCount={model.schema.items.length}
@ -655,6 +701,14 @@ export const RSEditState = ({
setEditors={setEditors}
/>
) : null}
{showEditLocation ? (
<DlgChangeLocation
hideWindow={() => setShowEditLocation(false)}
initial={model.schema.location}
onChangeLocation={handleSetLocation}
/>
) : null}
{showInlineSynthesis ? (
<DlgInlineSynthesis
receiver={model.schema}
@ -666,7 +720,9 @@ export const RSEditState = ({
) : null}
{model.loading ? <Loader /> : null}
{model.error ? <ProcessError error={model.error} isArchive={model.isArchive} schemaID={model.schemaID} /> : null}
{model.errorLoading ? (
<ProcessError error={model.errorLoading} isArchive={model.isArchive} schemaID={model.schemaID} />
) : null}
{model.schema && !model.loading ? children : null}
</RSEditContext.Provider>
);

View File

@ -239,7 +239,7 @@ function RSTabs() {
onSelect={onSelectTab}
defaultFocus
selectedTabClassName='clr-selected'
className='flex flex-col min-w-fit'
className='flex flex-col min-w-fit mx-auto'
>
<TabList className={clsx('mx-auto w-fit', 'flex items-stretch', 'border-b-2 border-x-2 divide-x-2')}>
<RSTabsMenu onDestroy={onDestroySchema} />

View File

@ -1,40 +0,0 @@
/* Depth layers */
.z-bottom {
z-index: 0;
}
.z-pop {
z-index: 10;
}
:is(.z-sticky, .sticky) {
z-index: 20;
}
.z-tooltip {
z-index: 30;
}
.z-navigation {
z-index: 50;
}
.z-modal {
z-index: 60;
}
.z-modal-controls {
z-index: 70;
}
.z-modal-top {
z-index: 80;
}
.z-modal-tooltip {
z-index: 90;
}
.z-topmost {
z-index: 99;
}

View File

@ -3,7 +3,6 @@
*/
@import './constants.css';
@import './layers.css';
@tailwind base;
@tailwind components;
@ -139,4 +138,8 @@ div:not(.dense) > p {
.border {
@apply rounded;
}
:is(.sticky) {
z-index: 20;
}
}

View File

@ -128,7 +128,7 @@ export function findContainedNodes(start: number, finish: number, tree: Tree, fi
export function domTooltipConstituenta(cst?: IConstituenta) {
const dom = document.createElement('div');
dom.className = clsx(
'z-modal-tooltip',
'z-modalTooltip',
'max-h-[25rem] max-w-[25rem] min-w-[10rem]',
'dense',
'p-2',

View File

@ -30,7 +30,8 @@ export const PARAMETER = {
* Numeric limitations.
*/
export const limits = {
library_alias_len: 12
library_alias_len: 12,
location_len: 500
};
/**
@ -94,7 +95,11 @@ export const storage = {
rseditShowList: 'rsedit.show_list',
rseditShowControls: 'rsedit.show_controls',
librarySearchStrategy: 'library.search.strategy',
librarySearchHead: 'library.search.head',
librarySearchVisible: 'library.search.visible',
librarySearchOwned: 'library.search.owned',
librarySearchSubscribed: 'library.search.subscribed',
librarySearchEditor: 'library.search.editor',
libraryPagination: 'library.pagination',
rsgraphFilter: 'rsgraph.filter2',
@ -138,7 +143,9 @@ export const prefixes = {
cst_delete_list: 'cst_delete_list_',
cst_dependant_list: 'cst_dependant_list_',
csttype_list: 'csttype_',
policy_list: 'policy_list_',
library_filters_list: 'library_filters_list_',
location_head_list: 'location_head_list_',
topic_list: 'topic_list_',
topic_item: 'topic_item_',
library_list: 'library_list_',

View File

@ -6,14 +6,8 @@
*/
import { GraphLayout } from '@/components/ui/GraphUI';
import { GramData, Grammeme, ReferenceType } from '@/models/language';
import {
CstMatchMode,
DependencyMode,
GraphColoring,
GraphSizing,
HelpTopic,
LibraryFilterStrategy
} from '@/models/miscellaneous';
import { AccessPolicy, LocationHead } from '@/models/library';
import { CstMatchMode, DependencyMode, GraphColoring, GraphSizing, HelpTopic } from '@/models/miscellaneous';
import { CstClass, CstType, ExpressionStatus, IConstituenta, IRSForm } from '@/models/rsform';
import {
IArgumentInfo,
@ -261,30 +255,28 @@ export function describeCstSource(mode: DependencyMode): string {
}
/**
* Retrieves label for {@link LibraryFilterStrategy}.
* Retrieves label for {@link LocationHead}.
*/
export function labelLibraryFilter(strategy: LibraryFilterStrategy): string {
export function labelLocationHead(head: LocationHead): string {
// prettier-ignore
switch (strategy) {
case LibraryFilterStrategy.MANUAL: return 'отображать все';
case LibraryFilterStrategy.COMMON: return 'общедоступные';
case LibraryFilterStrategy.CANONICAL: return 'неизменные';
case LibraryFilterStrategy.SUBSCRIBE: return 'подписки';
case LibraryFilterStrategy.OWNED: return 'владелец';
switch (head) {
case LocationHead.USER: return 'личные (/U)';
case LocationHead.COMMON: return 'общие (/S)';
case LocationHead.LIBRARY: return 'неизменные (/L)';
case LocationHead.PROJECTS: return 'проекты (/P)';
}
}
/**
* Retrieves description for {@link LibraryFilterStrategy}.
* Retrieves description for {@link LocationHead}.
*/
export function describeLibraryFilter(strategy: LibraryFilterStrategy): string {
export function describeLocationHead(head: LocationHead): string {
// prettier-ignore
switch (strategy) {
case LibraryFilterStrategy.MANUAL: return 'Отображать все схемы';
case LibraryFilterStrategy.COMMON: return 'Отображать общедоступные схемы';
case LibraryFilterStrategy.CANONICAL: return 'Отображать стандартные схемы';
case LibraryFilterStrategy.SUBSCRIBE: return 'Отображать подписки';
case LibraryFilterStrategy.OWNED: return 'Отображать собственные схемы';
switch (head) {
case LocationHead.USER: return 'Личные схемы пользователя';
case LocationHead.COMMON: return 'Рабочий каталог публичных схем';
case LocationHead.LIBRARY: return 'Каталог неизменных схем';
case LocationHead.PROJECTS: return 'Рабочий каталог проектных схем';
}
}
@ -802,9 +794,37 @@ export function describeAccessMode(mode: UserLevel): string {
}
}
/**
* Retrieves label for {@link AccessPolicy}.
*/
export function labelAccessPolicy(policy: AccessPolicy): string {
// prettier-ignore
switch (policy) {
case AccessPolicy.PRIVATE: return 'Личный';
case AccessPolicy.PROTECTED: return 'Защищенный';
case AccessPolicy.PUBLIC: return 'Открытый';
}
}
/**
* Retrieves description for {@link AccessPolicy}.
*/
export function describeAccessPolicy(policy: AccessPolicy): string {
// prettier-ignore
switch (policy) {
case AccessPolicy.PRIVATE:
return 'Доступ только для владельца';
case AccessPolicy.PROTECTED:
return 'Доступ для владельца и редакторов';
case AccessPolicy.PUBLIC:
return 'Открытый доступ';
}
}
/**
* UI shared messages.
*/
export const messages = {
unsaved: 'Сохраните или отмените изменения'
unsaved: 'Сохраните или отмените изменения',
promptUnsaved: 'Присутствуют несохраненные изменения. Продолжить без их учета?'
};

View File

@ -4,6 +4,8 @@
import { AxiosHeaderValue, AxiosResponse } from 'axios';
import { messages } from './labels';
/**
* Checks if arguments is Node.
*/
@ -100,5 +102,25 @@ export function convertBase64ToBlob(base64String: string): Uint8Array {
* Prompt user of confirming discarding changes before continue.
*/
export function promptUnsaved(): boolean {
return window.confirm('Присутствуют несохраненные изменения. Продолжить без их учета?');
return window.confirm(messages.promptUnsaved);
}
/**
* Toggle tristate flag: undefined - true - false.
*/
export function toggleTristateFlag(prev: boolean | undefined): boolean | undefined {
if (prev === undefined) {
return true;
}
return prev ? false : undefined;
}
/**
* Toggle tristate color: gray - green - red .
*/
export function tripleToggleColor(value: boolean | undefined): string {
if (value === undefined) {
return 'clr-text-controls';
}
return value ? 'clr-text-green' : 'clr-text-red';
}

View File

@ -3,6 +3,17 @@ export default {
darkMode: 'class',
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
zIndex: {
bottom: '0',
topmost: '99',
pop: '10',
sticky: '20',
tooltip: '30',
navigation: '50',
modal: '60',
modalControls: '70',
modalTooltip: '90'
},
extend: {}
},
plugins: []