mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 04:50:36 +03:00
Implementing locations and permissions pt1
This commit is contained in:
parent
64e1b028bc
commit
dc0555076b
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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/тест тест'))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
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:
|
||||
clone.save()
|
||||
|
||||
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'],
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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'
|
||||
)}
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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`,
|
||||
|
|
104
rsconcept/frontend/src/components/DomainIcons.tsx
Normal file
104
rsconcept/frontend/src/components/DomainIcons.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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}`} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>
|
||||
<Overlay position='top-[-0.125rem] left-3 translate-y-1/2' className='pointer-events-none clr-text-controls'>
|
||||
<IconSearch size='1.25rem' />
|
||||
</Overlay>
|
||||
{!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)}
|
||||
|
|
|
@ -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' />}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
62
rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx
Normal file
62
rsconcept/frontend/src/dialogs/DlgChangeLocation.tsx
Normal 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;
|
|
@ -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)}
|
||||
/>
|
||||
<TextInput
|
||||
id='dlg_alias'
|
||||
label='Сокращение'
|
||||
value={alias}
|
||||
className='max-w-sm'
|
||||
onChange={event => setAlias(event.target.value)}
|
||||
/>
|
||||
<div className='flex justify-between gap-3'>
|
||||
<TextInput
|
||||
id='dlg_alias'
|
||||
label='Сокращение'
|
||||
value={alias}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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}.
|
||||
*/
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
190
rsconcept/frontend/src/pages/CreateRSFormPage/FormCreateItem.tsx
Normal file
190
rsconcept/frontend/src/pages/CreateRSFormPage/FormCreateItem.tsx
Normal 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;
|
1
rsconcept/frontend/src/pages/CreateRSFormPage/index.tsx
Normal file
1
rsconcept/frontend/src/pages/CreateRSFormPage/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { default } from './CreateItemPage';
|
|
@ -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;
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
<SearchBar
|
||||
id='library_search'
|
||||
noBorder
|
||||
className='mx-auto min-w-[10rem]'
|
||||
value={query}
|
||||
onChange={handleChangeQuery}
|
||||
/>
|
||||
|
||||
<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='min-w-[10rem]'
|
||||
value={query}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,48 +102,34 @@ 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}
|
||||
data={items}
|
||||
headPosition='0'
|
||||
className='text-xs sm:text-sm cc-scroll-y'
|
||||
style={{ maxHeight: tableHeight }}
|
||||
noDataComponent={
|
||||
<FlexColumn className='p-3 items-center min-h-[6rem]'>
|
||||
<p>Список схем пуст</p>
|
||||
<p className='flex gap-6'>
|
||||
<TextURL text='Создать схему' href='/library/create' />
|
||||
<TextURL text='Очистить фильтр' onClick={resetQuery} />
|
||||
</p>
|
||||
</FlexColumn>
|
||||
}
|
||||
columnVisibility={columnVisibility}
|
||||
onRowClicked={handleOpenItem}
|
||||
enableSorting
|
||||
initialSorting={{
|
||||
id: 'time_update',
|
||||
desc: true
|
||||
}}
|
||||
enablePagination
|
||||
paginationPerPage={itemsPerPage}
|
||||
onChangePaginationOption={setItemsPerPage}
|
||||
paginationOptions={[10, 20, 30, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
<DataTable
|
||||
id='library_data'
|
||||
columns={columns}
|
||||
data={items}
|
||||
headPosition='0'
|
||||
className='text-xs sm:text-sm cc-scroll-y'
|
||||
style={{ maxHeight: tableHeight }}
|
||||
noDataComponent={
|
||||
<FlexColumn className='p-3 items-center min-h-[6rem]'>
|
||||
<p>Список схем пуст</p>
|
||||
<p className='flex gap-6'>
|
||||
<TextURL text='Создать схему' href='/library/create' />
|
||||
<TextURL text='Очистить фильтр' onClick={resetQuery} />
|
||||
</p>
|
||||
</FlexColumn>
|
||||
}
|
||||
columnVisibility={columnVisibility}
|
||||
onRowClicked={handleOpenItem}
|
||||
enableSorting
|
||||
initialSorting={{
|
||||
id: 'time_update',
|
||||
desc: true
|
||||
}}
|
||||
enablePagination
|
||||
paginationPerPage={itemsPerPage}
|
||||
onChangePaginationOption={setItemsPerPage}
|
||||
paginationOptions={[10, 20, 30, 50, 100]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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' />
|
||||
|
|
|
@ -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' />}
|
||||
/>
|
||||
<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>
|
||||
<VersionsToolbar />
|
||||
<AccessToolbar
|
||||
visible={visible}
|
||||
toggleVisible={() => setVisible(prev => !prev)}
|
||||
readOnly={readOnly}
|
||||
toggleReadOnly={() => setReadOnly(prev => !prev)}
|
||||
/>
|
||||
<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' />}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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_',
|
||||
|
|
|
@ -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: 'Присутствуют несохраненные изменения. Продолжить без их учета?'
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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: []
|
||||
|
|
Loading…
Reference in New Issue
Block a user