mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 04:50:36 +03:00
Implement NewConstituenta + add selector component
This commit is contained in:
parent
c9e56e7146
commit
e58fd183e9
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 4.2.1 on 2023-05-18 18:00
|
# Generated by Django 4.2.3 on 2023-07-23 11:55
|
||||||
|
|
||||||
import apps.rsform.models
|
import apps.rsform.models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -49,7 +49,6 @@ class Migration(migrations.Migration):
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Конституета',
|
'verbose_name': 'Конституета',
|
||||||
'verbose_name_plural': 'Конституенты',
|
'verbose_name_plural': 'Конституенты',
|
||||||
'unique_together': {('schema', 'alias'), ('schema', 'order')},
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
|
import json
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
|
|
||||||
|
import pyconcept
|
||||||
|
|
||||||
|
|
||||||
class CstType(models.TextChoices):
|
class CstType(models.TextChoices):
|
||||||
''' Type of constituenta '''
|
''' Type of constituenta '''
|
||||||
|
@ -77,29 +80,34 @@ class RSForm(models.Model):
|
||||||
''' Insert new constituenta at given position. All following constituents order is shifted by 1 position '''
|
''' Insert new constituenta at given position. All following constituents order is shifted by 1 position '''
|
||||||
if position <= 0:
|
if position <= 0:
|
||||||
raise ValidationError('Invalid position: should be positive integer')
|
raise ValidationError('Invalid position: should be positive integer')
|
||||||
update_list = Constituenta.objects.filter(schema=self, order__gte=position)
|
update_list = Constituenta.objects.only('id', 'order', 'schema').filter(schema=self, order__gte=position)
|
||||||
for cst in update_list:
|
for cst in update_list:
|
||||||
cst.order += 1
|
cst.order += 1
|
||||||
cst.save()
|
Constituenta.objects.bulk_update(update_list, ['order'])
|
||||||
return Constituenta.objects.create(
|
|
||||||
|
result = Constituenta.objects.create(
|
||||||
schema=self,
|
schema=self,
|
||||||
order=position,
|
order=position,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
csttype=type
|
csttype=type
|
||||||
)
|
)
|
||||||
|
self._recreate_order()
|
||||||
|
return Constituenta.objects.get(pk=result.pk)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def insert_last(self, alias: str, type: CstType) -> 'Constituenta':
|
def insert_last(self, alias: str, type: CstType) -> 'Constituenta':
|
||||||
''' Insert new constituenta at last position '''
|
''' Insert new constituenta at last position '''
|
||||||
position = 1
|
position = 1
|
||||||
if self.constituents().exists():
|
if self.constituents().exists():
|
||||||
position += self.constituents().aggregate(models.Max('order'))['order__max']
|
position += self.constituents().only('order').aggregate(models.Max('order'))['order__max']
|
||||||
return Constituenta.objects.create(
|
result = Constituenta.objects.create(
|
||||||
schema=self,
|
schema=self,
|
||||||
order=position,
|
order=position,
|
||||||
alias=alias,
|
alias=alias,
|
||||||
csttype=type
|
csttype=type
|
||||||
)
|
)
|
||||||
|
self._recreate_order()
|
||||||
|
return Constituenta.objects.get(pk=result.pk)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
@ -111,21 +119,7 @@ class RSForm(models.Model):
|
||||||
comment=data.get('comment', ''),
|
comment=data.get('comment', ''),
|
||||||
is_common=is_common
|
is_common=is_common
|
||||||
)
|
)
|
||||||
order = 1
|
schema._create_cst_from_json(data['items'])
|
||||||
for cst in data['items']:
|
|
||||||
# TODO: get rid of empty_term etc. Use None instead
|
|
||||||
Constituenta.objects.create(
|
|
||||||
alias=cst['alias'],
|
|
||||||
schema=schema,
|
|
||||||
order=order,
|
|
||||||
csttype=cst['cstType'],
|
|
||||||
convention=cst.get('convention', 'Без названия'),
|
|
||||||
definition_formal=cst['definition'].get('formal', '') if 'definition' in cst else '',
|
|
||||||
term=cst.get('term', _empty_term()),
|
|
||||||
definition_text=cst['definition']['text'] \
|
|
||||||
if 'definition' in cst and 'text' in cst['definition'] else _empty_definition() # noqa: E502
|
|
||||||
)
|
|
||||||
order += 1
|
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def to_json(self) -> str:
|
def to_json(self) -> str:
|
||||||
|
@ -133,7 +127,7 @@ class RSForm(models.Model):
|
||||||
result = self._prepare_json_rsform()
|
result = self._prepare_json_rsform()
|
||||||
items = self.constituents().order_by('order')
|
items = self.constituents().order_by('order')
|
||||||
for cst in items:
|
for cst in items:
|
||||||
result['items'].append(self._prepare_json_cst(cst))
|
result['items'].append(cst.to_json())
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -148,20 +142,37 @@ class RSForm(models.Model):
|
||||||
'items': []
|
'items': []
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
def _recreate_order(self):
|
||||||
def _prepare_json_cst(cst: 'Constituenta') -> dict:
|
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_json())))
|
||||||
return {
|
update_list = self.constituents().only('id', 'order')
|
||||||
'entityUID': cst.id,
|
if (len(checked['items']) != update_list.count()):
|
||||||
'type': 'constituenta',
|
raise ValidationError
|
||||||
'cstType': cst.csttype,
|
order = 1
|
||||||
'alias': cst.alias,
|
for cst in checked['items']:
|
||||||
'convention': cst.convention,
|
id = cst['entityUID']
|
||||||
'term': cst.term,
|
for oldCst in update_list:
|
||||||
'definition': {
|
if oldCst.id == id:
|
||||||
'formal': cst.definition_formal,
|
oldCst.order = order
|
||||||
'text': cst.definition_text
|
order += 1
|
||||||
}
|
break
|
||||||
}
|
Constituenta.objects.bulk_update(update_list, ['order'])
|
||||||
|
|
||||||
|
def _create_cst_from_json(self, items):
|
||||||
|
order = 1
|
||||||
|
for cst in items:
|
||||||
|
# TODO: get rid of empty_term etc. Use None instead
|
||||||
|
Constituenta.objects.create(
|
||||||
|
alias=cst['alias'],
|
||||||
|
schema=self,
|
||||||
|
order=order,
|
||||||
|
csttype=cst['cstType'],
|
||||||
|
convention=cst.get('convention', 'Без названия'),
|
||||||
|
definition_formal=cst['definition'].get('formal', '') if 'definition' in cst else '',
|
||||||
|
term=cst.get('term', _empty_term()),
|
||||||
|
definition_text=cst['definition']['text'] \
|
||||||
|
if 'definition' in cst and 'text' in cst['definition'] else _empty_definition() # noqa: E502
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
|
||||||
class Constituenta(models.Model):
|
class Constituenta(models.Model):
|
||||||
|
@ -208,7 +219,20 @@ class Constituenta(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = 'Конституета'
|
verbose_name = 'Конституета'
|
||||||
verbose_name_plural = 'Конституенты'
|
verbose_name_plural = 'Конституенты'
|
||||||
unique_together = (('schema', 'alias'), ('schema', 'order'))
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.alias
|
return self.alias
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
return {
|
||||||
|
'entityUID': self.id,
|
||||||
|
'type': 'constituenta',
|
||||||
|
'cstType': self.csttype,
|
||||||
|
'alias': self.alias,
|
||||||
|
'convention': self.convention,
|
||||||
|
'term': self.term,
|
||||||
|
'definition': {
|
||||||
|
'formal': self.definition_formal,
|
||||||
|
'text': self.definition_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -27,3 +27,9 @@ class ConstituentaSerializer(serializers.ModelSerializer):
|
||||||
def update(self, instance: Constituenta, validated_data):
|
def update(self, instance: Constituenta, validated_data):
|
||||||
instance.schema.save()
|
instance.schema.save()
|
||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class NewConstituentaSerializer(serializers.Serializer):
|
||||||
|
alias = serializers.CharField(max_length=8)
|
||||||
|
csttype = serializers.CharField(max_length=10)
|
||||||
|
insert_after = serializers.IntegerField(required=False)
|
||||||
|
|
|
@ -40,28 +40,6 @@ class TestConstituenta(TestCase):
|
||||||
with self.assertRaises(IntegrityError):
|
with self.assertRaises(IntegrityError):
|
||||||
Constituenta.objects.create(alias='X1', order=1)
|
Constituenta.objects.create(alias='X1', order=1)
|
||||||
|
|
||||||
def test_alias_unique(self):
|
|
||||||
alias = 'X1'
|
|
||||||
|
|
||||||
original = Constituenta.objects.create(alias=alias, order=1, schema=self.schema1)
|
|
||||||
self.assertIsNotNone(original)
|
|
||||||
|
|
||||||
clone = Constituenta.objects.create(alias=alias, order=2, schema=self.schema2)
|
|
||||||
self.assertNotEqual(clone, original)
|
|
||||||
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Constituenta.objects.create(alias=alias, order=1, schema=self.schema1)
|
|
||||||
|
|
||||||
def test_order_unique(self):
|
|
||||||
original = Constituenta.objects.create(alias='X1', order=1, schema=self.schema1)
|
|
||||||
self.assertIsNotNone(original)
|
|
||||||
|
|
||||||
clone = Constituenta.objects.create(alias='X2', order=1, schema=self.schema2)
|
|
||||||
self.assertNotEqual(clone, original)
|
|
||||||
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Constituenta.objects.create(alias='X2', order=1, schema=self.schema1)
|
|
||||||
|
|
||||||
def test_create_default(self):
|
def test_create_default(self):
|
||||||
cst = Constituenta.objects.create(
|
cst = Constituenta.objects.create(
|
||||||
alias='X1',
|
alias='X1',
|
||||||
|
@ -158,7 +136,7 @@ class TestRSForm(TestCase):
|
||||||
cst3 = schema.insert_at(4, 'X3', CstType.BASE)
|
cst3 = schema.insert_at(4, 'X3', CstType.BASE)
|
||||||
cst2.refresh_from_db()
|
cst2.refresh_from_db()
|
||||||
cst1.refresh_from_db()
|
cst1.refresh_from_db()
|
||||||
self.assertEqual(cst3.order, 4)
|
self.assertEqual(cst3.order, 3)
|
||||||
self.assertEqual(cst3.schema, schema)
|
self.assertEqual(cst3.schema, schema)
|
||||||
self.assertEqual(cst2.order, 1)
|
self.assertEqual(cst2.order, 1)
|
||||||
self.assertEqual(cst1.order, 2)
|
self.assertEqual(cst1.order, 2)
|
||||||
|
@ -169,13 +147,25 @@ class TestRSForm(TestCase):
|
||||||
cst1.refresh_from_db()
|
cst1.refresh_from_db()
|
||||||
self.assertEqual(cst4.order, 3)
|
self.assertEqual(cst4.order, 3)
|
||||||
self.assertEqual(cst4.schema, schema)
|
self.assertEqual(cst4.schema, schema)
|
||||||
self.assertEqual(cst3.order, 5)
|
self.assertEqual(cst3.order, 4)
|
||||||
self.assertEqual(cst2.order, 1)
|
self.assertEqual(cst2.order, 1)
|
||||||
self.assertEqual(cst1.order, 2)
|
self.assertEqual(cst1.order, 2)
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
schema.insert_at(0, 'X5', CstType.BASE)
|
schema.insert_at(0, 'X5', CstType.BASE)
|
||||||
|
|
||||||
|
def test_insert_at_reorder(self):
|
||||||
|
schema = RSForm.objects.create(title='Test')
|
||||||
|
schema.insert_at(1, 'X1', CstType.BASE)
|
||||||
|
d1 = schema.insert_at(2, 'D1', CstType.TERM)
|
||||||
|
d2 = schema.insert_at(1, 'D2', CstType.TERM)
|
||||||
|
d1.refresh_from_db()
|
||||||
|
self.assertEqual(d1.order, 3)
|
||||||
|
self.assertEqual(d2.order, 2)
|
||||||
|
|
||||||
|
x2 = schema.insert_at(4, 'X2', CstType.BASE)
|
||||||
|
self.assertEqual(x2.order, 2)
|
||||||
|
|
||||||
def test_insert_last(self):
|
def test_insert_last(self):
|
||||||
schema = RSForm.objects.create(title='Test')
|
schema = RSForm.objects.create(title='Test')
|
||||||
cst1 = schema.insert_last('X1', CstType.BASE)
|
cst1 = schema.insert_last('X1', CstType.BASE)
|
||||||
|
|
|
@ -171,6 +171,30 @@ class TestRSFormViewset(APITestCase):
|
||||||
response = self.client.post(f'/api/rsforms/{self.rsform_owned.id}/claim/')
|
response = self.client.post(f'/api/rsforms/{self.rsform_owned.id}/claim/')
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_create_constituenta(self):
|
||||||
|
data = json.dumps({'alias': 'X3', 'csttype': 'basic'})
|
||||||
|
response = self.client.post(f'/api/rsforms/{self.rsform_unowned.id}/new-constituenta/',
|
||||||
|
data=data, content_type='application/json')
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
schema = self.rsform_owned
|
||||||
|
Constituenta.objects.create(schema=schema, alias='X1', csttype='basic', order=1)
|
||||||
|
x2 = Constituenta.objects.create(schema=schema, alias='X2', csttype='basic', order=2)
|
||||||
|
response = self.client.post(f'/api/rsforms/{schema.id}/new-constituenta/',
|
||||||
|
data=data, content_type='application/json')
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.data['alias'], 'X3')
|
||||||
|
x3 = Constituenta.objects.get(alias=response.data['alias'])
|
||||||
|
self.assertEqual(x3.order, 3)
|
||||||
|
|
||||||
|
data = json.dumps({'alias': 'X4', 'csttype': 'basic', 'insert_after': x2.id})
|
||||||
|
response = self.client.post(f'/api/rsforms/{schema.id}/new-constituenta/',
|
||||||
|
data=data, content_type='application/json')
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.data['alias'], 'X4')
|
||||||
|
x4 = Constituenta.objects.get(alias=response.data['alias'])
|
||||||
|
self.assertEqual(x4.order, 3)
|
||||||
|
|
||||||
|
|
||||||
class TestFunctionalViews(APITestCase):
|
class TestFunctionalViews(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
@ -41,7 +41,8 @@ class RSFormViewSet(viewsets.ModelViewSet):
|
||||||
return serializer.save()
|
return serializer.save()
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in ['update', 'destroy', 'partial_update']:
|
if self.action in ['update', 'destroy', 'partial_update',
|
||||||
|
'new_constituenta']:
|
||||||
permission_classes = [utils.ObjectOwnerOrAdmin]
|
permission_classes = [utils.ObjectOwnerOrAdmin]
|
||||||
elif self.action in ['create', 'claim']:
|
elif self.action in ['create', 'claim']:
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
@ -49,6 +50,21 @@ class RSFormViewSet(viewsets.ModelViewSet):
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
return [permission() for permission in permission_classes]
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='new-constituenta')
|
||||||
|
def new_constituenta(self, request, pk):
|
||||||
|
''' View schema contents (including constituents) '''
|
||||||
|
schema: models.RSForm = self.get_object()
|
||||||
|
serializer = serializers.NewConstituentaSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
if ('insert_after' in serializer.validated_data):
|
||||||
|
cstafter = models.Constituenta.objects.get(pk=serializer.validated_data['insert_after'])
|
||||||
|
constituenta = schema.insert_at(cstafter.order + 1,
|
||||||
|
serializer.validated_data['alias'],
|
||||||
|
serializer.validated_data['csttype'])
|
||||||
|
else:
|
||||||
|
constituenta = schema.insert_last(serializer.validated_data['alias'], serializer.validated_data['csttype'])
|
||||||
|
return Response(status=201, data=constituenta.to_json())
|
||||||
|
|
||||||
@action(detail=True, methods=['post'])
|
@action(detail=True, methods=['post'])
|
||||||
def claim(self, request, pk=None):
|
def claim(self, request, pk=None):
|
||||||
schema: models.RSForm = self.get_object()
|
schema: models.RSForm = self.get_object()
|
||||||
|
|
|
@ -8,9 +8,8 @@ from django.conf.urls.static import static
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('', lambda request: redirect('docs/', permanent=True)),
|
|
||||||
path('docs/', include_docs_urls(title='ConceptPortal API'),
|
|
||||||
name='docs'),
|
|
||||||
path('api/', include('apps.rsform.urls')),
|
path('api/', include('apps.rsform.urls')),
|
||||||
path('users/', include('apps.users.urls')),
|
path('users/', include('apps.users.urls')),
|
||||||
|
path('docs/', include_docs_urls(title='ConceptPortal API'), name='docs'),
|
||||||
|
path('', lambda request: redirect('docs/', permanent=True)),
|
||||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
|
211
rsconcept/frontend/package-lock.json
generated
211
rsconcept/frontend/package-lock.json
generated
|
@ -25,6 +25,7 @@
|
||||||
"react-loader-spinner": "^5.3.4",
|
"react-loader-spinner": "^5.3.4",
|
||||||
"react-router-dom": "^6.12.1",
|
"react-router-dom": "^6.12.1",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
|
"react-select": "^5.7.4",
|
||||||
"react-tabs": "^6.0.1",
|
"react-tabs": "^6.0.1",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"styled-components": "^6.0.4",
|
"styled-components": "^6.0.4",
|
||||||
|
@ -2369,6 +2370,70 @@
|
||||||
"postcss-selector-parser": "^6.0.10"
|
"postcss-selector-parser": "^6.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emotion/babel-plugin": {
|
||||||
|
"version": "11.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
|
||||||
|
"integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-module-imports": "^7.16.7",
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"@emotion/hash": "^0.9.1",
|
||||||
|
"@emotion/memoize": "^0.8.1",
|
||||||
|
"@emotion/serialize": "^1.1.2",
|
||||||
|
"babel-plugin-macros": "^3.1.0",
|
||||||
|
"convert-source-map": "^1.5.0",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"find-root": "^1.1.0",
|
||||||
|
"source-map": "^0.5.7",
|
||||||
|
"stylis": "4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
|
||||||
|
"version": "0.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
|
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/babel-plugin/node_modules/stylis": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/cache": {
|
||||||
|
"version": "11.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz",
|
||||||
|
"integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/memoize": "^0.8.1",
|
||||||
|
"@emotion/sheet": "^1.2.2",
|
||||||
|
"@emotion/utils": "^1.2.1",
|
||||||
|
"@emotion/weak-memoize": "^0.3.1",
|
||||||
|
"stylis": "4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/cache/node_modules/stylis": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/hash": {
|
||||||
|
"version": "0.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz",
|
||||||
|
"integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ=="
|
||||||
|
},
|
||||||
"node_modules/@emotion/is-prop-valid": {
|
"node_modules/@emotion/is-prop-valid": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
|
||||||
|
@ -2382,6 +2447,46 @@
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
|
||||||
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
|
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@emotion/react": {
|
||||||
|
"version": "11.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz",
|
||||||
|
"integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"@emotion/babel-plugin": "^11.11.0",
|
||||||
|
"@emotion/cache": "^11.11.0",
|
||||||
|
"@emotion/serialize": "^1.1.2",
|
||||||
|
"@emotion/use-insertion-effect-with-fallbacks": "^1.0.1",
|
||||||
|
"@emotion/utils": "^1.2.1",
|
||||||
|
"@emotion/weak-memoize": "^0.3.1",
|
||||||
|
"hoist-non-react-statics": "^3.3.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/serialize": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/hash": "^0.9.1",
|
||||||
|
"@emotion/memoize": "^0.8.1",
|
||||||
|
"@emotion/unitless": "^0.8.1",
|
||||||
|
"@emotion/utils": "^1.2.1",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/sheet": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA=="
|
||||||
|
},
|
||||||
"node_modules/@emotion/stylis": {
|
"node_modules/@emotion/stylis": {
|
||||||
"version": "0.8.5",
|
"version": "0.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz",
|
||||||
|
@ -2392,6 +2497,24 @@
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
|
||||||
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
|
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/utils": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg=="
|
||||||
|
},
|
||||||
|
"node_modules/@emotion/weak-memoize": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww=="
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||||
|
@ -2485,6 +2608,19 @@
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-Bu+AMaXNjrpjh41znzHqaz3r2Nr8hHuHZT6V2LBKMhyMl0FgKA62PNYbqnfgmzOhoWZj70Zecisbo4H1rotP5g=="
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-96KnRWkRnuBSSFbj0sFGwwOUd8EkiecINVl0O9wiZlZ64EkpyAOG3Xc2vKKNJmru0Z7RqWNymA+6b8OZqjgyyw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@formatjs/ecma402-abstract": {
|
"node_modules/@formatjs/ecma402-abstract": {
|
||||||
"version": "1.17.0",
|
"version": "1.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.0.tgz",
|
||||||
|
@ -4330,6 +4466,14 @@
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-transition-group": {
|
||||||
|
"version": "4.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz",
|
||||||
|
"integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.17.1",
|
"version": "1.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
|
||||||
|
@ -7008,6 +7152,15 @@
|
||||||
"utila": "~0.4"
|
"utila": "~0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-serializer": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||||
|
@ -8316,6 +8469,11 @@
|
||||||
"url": "https://github.com/avajs/find-cache-dir?sponsor=1"
|
"url": "https://github.com/avajs/find-cache-dir?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-root": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
|
||||||
|
},
|
||||||
"node_modules/find-up": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
|
@ -12261,6 +12419,11 @@
|
||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
|
||||||
|
},
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
|
||||||
|
@ -14975,6 +15138,26 @@
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/react-select": {
|
||||||
|
"version": "5.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.7.4.tgz",
|
||||||
|
"integrity": "sha512-NhuE56X+p9QDFh4BgeygHFIvJJszO1i1KSkg/JPcIJrbovyRtI+GuOEa4XzFCEpZRAEoEI8u/cAHK+jG/PgUzQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.0",
|
||||||
|
"@emotion/cache": "^11.4.0",
|
||||||
|
"@emotion/react": "^11.8.1",
|
||||||
|
"@floating-ui/dom": "^1.0.1",
|
||||||
|
"@types/react-transition-group": "^4.4.0",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"prop-types": "^15.6.0",
|
||||||
|
"react-transition-group": "^4.3.0",
|
||||||
|
"use-isomorphic-layout-effect": "^1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-tabs": {
|
"node_modules/react-tabs": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-6.0.1.tgz",
|
||||||
|
@ -14999,6 +15182,21 @@
|
||||||
"react-dom": ">=16"
|
"react-dom": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.6.0",
|
||||||
|
"react-dom": ">=16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
@ -16923,6 +17121,19 @@
|
||||||
"requires-port": "^1.0.0"
|
"requires-port": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
"react-loader-spinner": "^5.3.4",
|
"react-loader-spinner": "^5.3.4",
|
||||||
"react-router-dom": "^6.12.1",
|
"react-router-dom": "^6.12.1",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
|
"react-select": "^5.7.4",
|
||||||
"react-tabs": "^6.0.1",
|
"react-tabs": "^6.0.1",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"styled-components": "^6.0.4",
|
"styled-components": "^6.0.4",
|
||||||
|
|
|
@ -8,35 +8,38 @@ interface BackendErrorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DescribeError(error: ErrorInfo) {
|
function DescribeError(error: ErrorInfo) {
|
||||||
|
console.log(error);
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return <p>Ошибки отсутствуют</p>;
|
return <p>Ошибки отсутствуют</p>;
|
||||||
} else if (typeof error === 'string') {
|
} else if (typeof error === 'string') {
|
||||||
return <p>{error}</p>;
|
return <p>{error}</p>;
|
||||||
} else if (axios.isAxiosError(error)) {
|
} else if (!axios.isAxiosError(error)) {
|
||||||
if (!error?.response) {
|
|
||||||
return <p>Нет ответа от сервера</p>;
|
|
||||||
}
|
|
||||||
if (error.response.status === 404) {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col justify-start'>
|
|
||||||
<p>{`Обращение к несуществующему API`}</p>
|
|
||||||
<PrettyJson data={error} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col justify-start'>
|
|
||||||
<p className='underline'>Ошибка</p>
|
|
||||||
<p>{error.message}</p>
|
|
||||||
{error.response.data && (<>
|
|
||||||
<p className='mt-2 underline'>Описание</p>
|
|
||||||
<PrettyJson data={error.response.data} />
|
|
||||||
</>)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <PrettyJson data={error} />;
|
return <PrettyJson data={error} />;
|
||||||
}
|
}
|
||||||
|
if (!error?.response) {
|
||||||
|
return <p>Нет ответа от сервера</p>;
|
||||||
|
}
|
||||||
|
if (error.response.status === 404) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col justify-start'>
|
||||||
|
<p>{`Обращение к несуществующему API`}</p>
|
||||||
|
<PrettyJson data={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHtml = error.response.headers['content-type'].includes('text/html');
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col justify-start'>
|
||||||
|
<p className='underline'>Ошибка</p>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
{error.response.data && (<>
|
||||||
|
<p className='mt-2 underline'>Описание</p>
|
||||||
|
{ isHtml && <div dangerouslySetInnerHTML={{ __html: error.response.data }} /> }
|
||||||
|
{ !isHtml && <PrettyJson data={error.response.data} />}
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackendError({error}: BackendErrorProps) {
|
function BackendError({error}: BackendErrorProps) {
|
||||||
|
|
|
@ -26,6 +26,11 @@ function Modal({title, show, toggle, onSubmit, onCancel, canSubmit, children, su
|
||||||
if(onCancel) onCancel();
|
if(onCancel) onCancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
toggle();
|
||||||
|
onSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='fixed top-0 left-0 w-full h-full clr-modal opacity-50 z-50'>
|
<div className='fixed top-0 left-0 w-full h-full clr-modal opacity-50 z-50'>
|
||||||
|
@ -41,7 +46,7 @@ function Modal({title, show, toggle, onSubmit, onCancel, canSubmit, children, su
|
||||||
widthClass='min-w-[6rem] w-fit h-fit'
|
widthClass='min-w-[6rem] w-fit h-fit'
|
||||||
colorClass='clr-btn-primary'
|
colorClass='clr-btn-primary'
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
onClick={onSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
text='Отмена'
|
text='Отмена'
|
||||||
|
|
|
@ -1,21 +1,16 @@
|
||||||
|
import { InputHTMLAttributes } from 'react';
|
||||||
import Label from './Label';
|
import Label from './Label';
|
||||||
|
|
||||||
interface TextInputProps {
|
interface TextInputProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
|
||||||
label: string
|
label: string
|
||||||
required?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
placeholder?: string
|
|
||||||
widthClass?: string
|
widthClass?: string
|
||||||
value?: any
|
|
||||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
|
|
||||||
onFocus?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextInput({
|
function TextInput({
|
||||||
id, type, required, label, disabled, placeholder, widthClass='w-full', value,
|
id, required, label, widthClass='w-full',
|
||||||
onChange, onFocus
|
...props
|
||||||
}: TextInputProps) {
|
}: TextInputProps) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3'>
|
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3'>
|
||||||
|
@ -27,12 +22,7 @@ function TextInput({
|
||||||
<input id={id}
|
<input id={id}
|
||||||
className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 truncate hover:text-clip '+ widthClass}
|
className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 truncate hover:text-clip '+ widthClass}
|
||||||
required={required}
|
required={required}
|
||||||
type={type}
|
{...props}
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
onFocus={onFocus}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { IConstituenta, IRSForm } from '../utils/models';
|
||||||
import { useRSFormDetails } from '../hooks/useRSFormDetails';
|
import { useRSFormDetails } from '../hooks/useRSFormDetails';
|
||||||
import { ErrorInfo } from '../components/BackendError';
|
import { ErrorInfo } from '../components/BackendError';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { BackendCallback, deleteRSForm, getTRSFile, patchConstituenta, patchRSForm, postClaimRSForm } from '../utils/backendAPI';
|
import { BackendCallback, deleteRSForm, getTRSFile, patchConstituenta, patchRSForm, postClaimRSForm, postNewConstituenta } from '../utils/backendAPI';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
interface IRSFormContext {
|
interface IRSFormContext {
|
||||||
|
@ -30,6 +30,7 @@ interface IRSFormContext {
|
||||||
download: (callback: BackendCallback) => void
|
download: (callback: BackendCallback) => void
|
||||||
|
|
||||||
cstUpdate: (data: any, callback: BackendCallback) => void
|
cstUpdate: (data: any, callback: BackendCallback) => void
|
||||||
|
cstCreate: (data: any, callback: BackendCallback) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RSFormContext = createContext<IRSFormContext>({
|
export const RSFormContext = createContext<IRSFormContext>({
|
||||||
|
@ -56,16 +57,17 @@ export const RSFormContext = createContext<IRSFormContext>({
|
||||||
download: () => {},
|
download: () => {},
|
||||||
|
|
||||||
cstUpdate: () => {},
|
cstUpdate: () => {},
|
||||||
|
cstCreate: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
interface RSFormStateProps {
|
interface RSFormStateProps {
|
||||||
id: string
|
schemaID: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { schema, reload, error, setError, loading } = useRSFormDetails({target: id});
|
const { schema, reload, error, setError, loading } = useRSFormDetails({target: schemaID});
|
||||||
const [processing, setProcessing] = useState(false)
|
const [processing, setProcessing] = useState(false)
|
||||||
const [active, setActive] = useState<IConstituenta | undefined>(undefined);
|
const [active, setActive] = useState<IConstituenta | undefined>(undefined);
|
||||||
|
|
||||||
|
@ -91,7 +93,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
||||||
|
|
||||||
async function update(data: any, callback?: BackendCallback) {
|
async function update(data: any, callback?: BackendCallback) {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
patchRSForm(id, {
|
patchRSForm(schemaID, {
|
||||||
data: data,
|
data: data,
|
||||||
showError: true,
|
showError: true,
|
||||||
setLoading: setProcessing,
|
setLoading: setProcessing,
|
||||||
|
@ -102,7 +104,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
||||||
|
|
||||||
async function destroy(callback: BackendCallback) {
|
async function destroy(callback: BackendCallback) {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
deleteRSForm(id, {
|
deleteRSForm(schemaID, {
|
||||||
showError: true,
|
showError: true,
|
||||||
setLoading: setProcessing,
|
setLoading: setProcessing,
|
||||||
onError: error => setError(error),
|
onError: error => setError(error),
|
||||||
|
@ -112,7 +114,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
||||||
|
|
||||||
async function claim(callback: BackendCallback) {
|
async function claim(callback: BackendCallback) {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
postClaimRSForm(id, {
|
postClaimRSForm(schemaID, {
|
||||||
showError: true,
|
showError: true,
|
||||||
setLoading: setProcessing,
|
setLoading: setProcessing,
|
||||||
onError: error => setError(error),
|
onError: error => setError(error),
|
||||||
|
@ -122,7 +124,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
||||||
|
|
||||||
async function download(callback: BackendCallback) {
|
async function download(callback: BackendCallback) {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
getTRSFile(id, {
|
getTRSFile(schemaID, {
|
||||||
showError: true,
|
showError: true,
|
||||||
setLoading: setProcessing,
|
setLoading: setProcessing,
|
||||||
onError: error => setError(error),
|
onError: error => setError(error),
|
||||||
|
@ -141,6 +143,17 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cstCreate(data: any, callback?: BackendCallback) {
|
||||||
|
setError(undefined);
|
||||||
|
postNewConstituenta(schemaID, {
|
||||||
|
data: data,
|
||||||
|
showError: true,
|
||||||
|
setLoading: setProcessing,
|
||||||
|
onError: error => setError(error),
|
||||||
|
onSucccess: callback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RSFormContext.Provider value={{
|
<RSFormContext.Provider value={{
|
||||||
schema, error, loading, processing,
|
schema, error, loading, processing,
|
||||||
|
@ -150,8 +163,8 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
|
||||||
toggleReadonly: () => setReadonly(prev => !prev),
|
toggleReadonly: () => setReadonly(prev => !prev),
|
||||||
isOwned, isEditable, isClaimable,
|
isOwned, isEditable, isClaimable,
|
||||||
isTracking, toggleTracking,
|
isTracking, toggleTracking,
|
||||||
cstUpdate,
|
reload, update, download, destroy, claim,
|
||||||
reload, update, download, destroy, claim
|
cstUpdate, cstCreate
|
||||||
}}>
|
}}>
|
||||||
{ children }
|
{ children }
|
||||||
</RSFormContext.Provider>
|
</RSFormContext.Provider>
|
||||||
|
|
|
@ -44,6 +44,7 @@ function LoginPage() {
|
||||||
required
|
required
|
||||||
type='text'
|
type='text'
|
||||||
value={username}
|
value={username}
|
||||||
|
autoFocus
|
||||||
onChange={event => setUsername(event.target.value)}
|
onChange={event => setUsername(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextInput id='password'
|
<TextInput id='password'
|
||||||
|
|
|
@ -1,29 +1,25 @@
|
||||||
import { CstType, IConstituenta, ParsingStatus, ValueClass, inferStatus } from '../../utils/models'
|
import { CstType, IConstituenta, INewCstData, ParsingStatus, ValueClass, inferStatus } from '../../utils/models'
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import DataTableThemed, { SelectionInfo } from '../../components/Common/DataTableThemed';
|
import DataTableThemed from '../../components/Common/DataTableThemed';
|
||||||
import { useRSForm } from '../../context/RSFormContext';
|
import { useRSForm } from '../../context/RSFormContext';
|
||||||
import Button from '../../components/Common/Button';
|
import Button from '../../components/Common/Button';
|
||||||
import { ArrowDownIcon, ArrowUpIcon, ArrowsRotateIcon, DumpBinIcon, SmallPlusIcon } from '../../components/Icons';
|
import { ArrowDownIcon, ArrowUpIcon, ArrowsRotateIcon, DumpBinIcon, SmallPlusIcon } from '../../components/Icons';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import Divider from '../../components/Common/Divider';
|
import Divider from '../../components/Common/Divider';
|
||||||
import { getCstTypeLabel, getCstTypePrefix, getStatusInfo, getTypeLabel } from '../../utils/staticUI';
|
import { createAliasFor, getCstTypeLabel, getCstTypePrefix, getStatusInfo, getTypeLabel } from '../../utils/staticUI';
|
||||||
import CreateCstModal from './CreateCstModal';
|
import CreateCstModal from './CreateCstModal';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
|
||||||
interface ConstituentsTableProps {
|
interface ConstituentsTableProps {
|
||||||
onOpenEdit: (cst: IConstituenta) => void
|
onOpenEdit: (cst: IConstituenta) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
|
function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
|
||||||
const { schema, isEditable, } = useRSForm();
|
const { schema, isEditable, cstCreate, reload } = useRSForm();
|
||||||
const [selectedRows, setSelectedRows] = useState<IConstituenta[]>([]);
|
const [selectedRows, setSelectedRows] = useState<IConstituenta[]>([]);
|
||||||
const nothingSelected = useMemo(() => selectedRows.length === 0, [selectedRows]);
|
const nothingSelected = useMemo(() => selectedRows.length === 0, [selectedRows]);
|
||||||
|
|
||||||
const [showCstModal, setShowCstModal] = useState(true);
|
const [showCstModal, setShowCstModal] = useState(false);
|
||||||
|
|
||||||
const handleRowSelected = useCallback(
|
|
||||||
({selectedRows} : SelectionInfo<IConstituenta>) => {
|
|
||||||
setSelectedRows(selectedRows);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRowClicked = useCallback(
|
const handleRowClicked = useCallback(
|
||||||
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
|
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
|
||||||
|
@ -48,13 +44,23 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
|
||||||
toast.info('Переиндексация');
|
toast.info('Переиндексация');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddNew = useCallback((cstType?: CstType) => {
|
const handleAddNew = useCallback((csttype?: CstType) => {
|
||||||
if (!cstType) {
|
if (!csttype) {
|
||||||
setShowCstModal(true);
|
setShowCstModal(true);
|
||||||
} else {
|
} else {
|
||||||
toast.info(`Новая конституента ${cstType || 'NEW'}`);
|
let data: INewCstData = {
|
||||||
|
csttype: csttype,
|
||||||
|
alias: createAliasFor(csttype, schema!)
|
||||||
|
}
|
||||||
|
if (selectedRows.length > 0) {
|
||||||
|
data['insert_after'] = selectedRows[selectedRows.length - 1].entityUID
|
||||||
|
}
|
||||||
|
cstCreate(data, (response: AxiosResponse) => {
|
||||||
|
reload();
|
||||||
|
toast.info(`Добавлена конституента ${response.data['alias']}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, [schema, selectedRows, reload, cstCreate]);
|
||||||
|
|
||||||
const columns = useMemo(() =>
|
const columns = useMemo(() =>
|
||||||
[
|
[
|
||||||
|
@ -228,19 +234,20 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
|
||||||
data={schema!.items!}
|
data={schema!.items!}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
keyField='id'
|
keyField='id'
|
||||||
noDataComponent={<span className='p-2 flex flex-col justify-center text-center'>
|
noDataComponent={
|
||||||
<p>Список пуст</p>
|
<span className='flex flex-col justify-center p-2 text-center'>
|
||||||
<p>Создайте новую конституенту</p>
|
<p>Список пуст</p>
|
||||||
</span>}
|
<p>Создайте новую конституенту</p>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
striped
|
striped
|
||||||
highlightOnHover
|
highlightOnHover
|
||||||
pointerOnHover
|
pointerOnHover
|
||||||
|
|
||||||
selectableRows
|
selectableRows
|
||||||
// selectableRowSelected={(cst) => selectedRows.indexOf(cst) < -1}
|
|
||||||
selectableRowsHighlight
|
selectableRowsHighlight
|
||||||
onSelectedRowsChange={handleRowSelected}
|
onSelectedRowsChange={({selectedRows}) => setSelectedRows(selectedRows)}
|
||||||
onRowDoubleClicked={onOpenEdit}
|
onRowDoubleClicked={onOpenEdit}
|
||||||
onRowClicked={handleRowClicked}
|
onRowClicked={handleRowClicked}
|
||||||
dense
|
dense
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import Modal from '../../components/Common/Modal';
|
import Modal from '../../components/Common/Modal';
|
||||||
import { CstType } from '../../utils/models';
|
import { CstType } from '../../utils/models';
|
||||||
import { useState } from 'react';
|
import Select from 'react-select';
|
||||||
|
import { CstTypeSelector } from '../../utils/staticUI';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface CreateCstModalProps {
|
interface CreateCstModalProps {
|
||||||
show: boolean
|
show: boolean
|
||||||
|
@ -11,11 +12,17 @@ interface CreateCstModalProps {
|
||||||
|
|
||||||
function CreateCstModal({show, toggle, onCreate}: CreateCstModalProps) {
|
function CreateCstModal({show, toggle, onCreate}: CreateCstModalProps) {
|
||||||
const [validated, setValidated] = useState(false);
|
const [validated, setValidated] = useState(false);
|
||||||
|
const [selectedType, setSelectedType] = useState<CstType|undefined>(undefined);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
toast.info('Создание конституент');
|
if (selectedType) onCreate(selectedType);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValidated(selectedType !== undefined);
|
||||||
|
}, [selectedType]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title='Создание конституенты'
|
title='Создание конституенты'
|
||||||
|
@ -24,8 +31,11 @@ function CreateCstModal({show, toggle, onCreate}: CreateCstModalProps) {
|
||||||
canSubmit={validated}
|
canSubmit={validated}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<p>Выбор типа конституенты</p>
|
<Select
|
||||||
<p>Добавить после выбранной или в конец</p>
|
options={CstTypeSelector}
|
||||||
|
placeholder='Выберите тип'
|
||||||
|
onChange={(data) => setSelectedType(data?.value)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import RSFormTabs from './RSFormTabs';
|
||||||
function RSFormPage() {
|
function RSFormPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
<RSFormState id={id || ''}>
|
<RSFormState schemaID={id || ''}>
|
||||||
<RSFormTabs />
|
<RSFormTabs />
|
||||||
</RSFormState>
|
</RSFormState>
|
||||||
);
|
);
|
||||||
|
|
|
@ -150,6 +150,14 @@ export async function postCheckExpression(schema: string, request?: IFrontReques
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postNewConstituenta(schema: string, request?: IFrontRequest) {
|
||||||
|
AxiosPost({
|
||||||
|
title: `New Constituenta for RSForm id=${schema}: ${request?.data['alias']}`,
|
||||||
|
endpoint: `${config.url.BASE}rsforms/${schema}/new-constituenta/`,
|
||||||
|
request: request
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ====== Helper functions ===========
|
// ====== Helper functions ===========
|
||||||
function AxiosGet<ReturnType>({endpoint, request, title}: IAxiosRequest) {
|
function AxiosGet<ReturnType>({endpoint, request, title}: IAxiosRequest) {
|
||||||
|
|
|
@ -32,6 +32,13 @@ export interface IUserSignupData {
|
||||||
password2: string
|
password2: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User data for signup
|
||||||
|
export interface INewCstData {
|
||||||
|
alias: string
|
||||||
|
csttype: CstType
|
||||||
|
insert_after?: number
|
||||||
|
}
|
||||||
|
|
||||||
// Constituenta type
|
// Constituenta type
|
||||||
export enum CstType {
|
export enum CstType {
|
||||||
BASE = 'basic',
|
BASE = 'basic',
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { CstType, ExpressionStatus, IConstituenta, ParsingStatus, TokenID } from './models';
|
import { CstType, ExpressionStatus, IConstituenta, IRSForm, ParsingStatus, TokenID } from './models';
|
||||||
|
|
||||||
export interface IRSButtonData {
|
export interface IRSButtonData {
|
||||||
text: string
|
text: string
|
||||||
|
@ -195,6 +195,12 @@ export function getCstTypeLabel(type: CstType) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CstTypeSelector = (Object.values(CstType)).map(
|
||||||
|
(typeStr) => {
|
||||||
|
const type = typeStr as CstType;
|
||||||
|
return {value: type, label: getCstTypeLabel(type)};
|
||||||
|
});
|
||||||
|
|
||||||
export function getCstTypePrefix(type: CstType) {
|
export function getCstTypePrefix(type: CstType) {
|
||||||
switch(type) {
|
switch(type) {
|
||||||
case CstType.BASE: return 'X';
|
case CstType.BASE: return 'X';
|
||||||
|
@ -250,4 +256,20 @@ export function getStatusInfo(status?: ExpressionStatus): IStatusInfo {
|
||||||
|
|
||||||
export function extractGlobals(expression: string): Set<string> {
|
export function extractGlobals(expression: string): Set<string> {
|
||||||
return new Set(expression.match(/[XCSADFPT]\d+/g) || []);
|
return new Set(expression.match(/[XCSADFPT]\d+/g) || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAliasFor(type: CstType, schema: IRSForm): string {
|
||||||
|
let index = 1;
|
||||||
|
let prefix = getCstTypePrefix(type);
|
||||||
|
let name = prefix + index;
|
||||||
|
if (schema.items && schema.items.length > 0) {
|
||||||
|
for (let i = 0; i < schema.items.length; ++i) {
|
||||||
|
if (schema.items[i].alias === name) {
|
||||||
|
++index;
|
||||||
|
name = prefix + index;
|
||||||
|
i = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name;
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user