Implement NewConstituenta + add selector component

This commit is contained in:
IRBorisov 2023-07-23 15:23:01 +03:00
parent c9e56e7146
commit e58fd183e9
20 changed files with 479 additions and 143 deletions

View File

@ -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
from django.conf import settings
@ -49,7 +49,6 @@ class Migration(migrations.Migration):
options={
'verbose_name': 'Конституета',
'verbose_name_plural': 'Конституенты',
'unique_together': {('schema', 'alias'), ('schema', 'order')},
},
),
]

View File

@ -1,8 +1,11 @@
import json
from django.db import models, transaction
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from apps.users.models import User
import pyconcept
class CstType(models.TextChoices):
''' 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 '''
if position <= 0:
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:
cst.order += 1
cst.save()
return Constituenta.objects.create(
Constituenta.objects.bulk_update(update_list, ['order'])
result = Constituenta.objects.create(
schema=self,
order=position,
alias=alias,
csttype=type
)
self._recreate_order()
return Constituenta.objects.get(pk=result.pk)
@transaction.atomic
def insert_last(self, alias: str, type: CstType) -> 'Constituenta':
''' Insert new constituenta at last position '''
position = 1
if self.constituents().exists():
position += self.constituents().aggregate(models.Max('order'))['order__max']
return Constituenta.objects.create(
position += self.constituents().only('order').aggregate(models.Max('order'))['order__max']
result = Constituenta.objects.create(
schema=self,
order=position,
alias=alias,
csttype=type
)
self._recreate_order()
return Constituenta.objects.get(pk=result.pk)
@staticmethod
@transaction.atomic
@ -111,21 +119,7 @@ class RSForm(models.Model):
comment=data.get('comment', ''),
is_common=is_common
)
order = 1
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
schema._create_cst_from_json(data['items'])
return schema
def to_json(self) -> str:
@ -133,7 +127,7 @@ class RSForm(models.Model):
result = self._prepare_json_rsform()
items = self.constituents().order_by('order')
for cst in items:
result['items'].append(self._prepare_json_cst(cst))
result['items'].append(cst.to_json())
return result
def __str__(self):
@ -148,20 +142,37 @@ class RSForm(models.Model):
'items': []
}
@staticmethod
def _prepare_json_cst(cst: 'Constituenta') -> dict:
return {
'entityUID': cst.id,
'type': 'constituenta',
'cstType': cst.csttype,
'alias': cst.alias,
'convention': cst.convention,
'term': cst.term,
'definition': {
'formal': cst.definition_formal,
'text': cst.definition_text
}
}
def _recreate_order(self):
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_json())))
update_list = self.constituents().only('id', 'order')
if (len(checked['items']) != update_list.count()):
raise ValidationError
order = 1
for cst in checked['items']:
id = cst['entityUID']
for oldCst in update_list:
if oldCst.id == id:
oldCst.order = order
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):
@ -208,7 +219,20 @@ class Constituenta(models.Model):
class Meta:
verbose_name = 'Конституета'
verbose_name_plural = 'Конституенты'
unique_together = (('schema', 'alias'), ('schema', 'order'))
def __str__(self):
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
}
}

View File

@ -27,3 +27,9 @@ class ConstituentaSerializer(serializers.ModelSerializer):
def update(self, instance: Constituenta, validated_data):
instance.schema.save()
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)

View File

@ -40,28 +40,6 @@ class TestConstituenta(TestCase):
with self.assertRaises(IntegrityError):
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):
cst = Constituenta.objects.create(
alias='X1',
@ -158,7 +136,7 @@ class TestRSForm(TestCase):
cst3 = schema.insert_at(4, 'X3', CstType.BASE)
cst2.refresh_from_db()
cst1.refresh_from_db()
self.assertEqual(cst3.order, 4)
self.assertEqual(cst3.order, 3)
self.assertEqual(cst3.schema, schema)
self.assertEqual(cst2.order, 1)
self.assertEqual(cst1.order, 2)
@ -169,13 +147,25 @@ class TestRSForm(TestCase):
cst1.refresh_from_db()
self.assertEqual(cst4.order, 3)
self.assertEqual(cst4.schema, schema)
self.assertEqual(cst3.order, 5)
self.assertEqual(cst3.order, 4)
self.assertEqual(cst2.order, 1)
self.assertEqual(cst1.order, 2)
with self.assertRaises(ValidationError):
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):
schema = RSForm.objects.create(title='Test')
cst1 = schema.insert_last('X1', CstType.BASE)

View File

@ -171,6 +171,30 @@ class TestRSFormViewset(APITestCase):
response = self.client.post(f'/api/rsforms/{self.rsform_owned.id}/claim/')
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):
def setUp(self):

View File

@ -41,7 +41,8 @@ class RSFormViewSet(viewsets.ModelViewSet):
return serializer.save()
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]
elif self.action in ['create', 'claim']:
permission_classes = [permissions.IsAuthenticated]
@ -49,6 +50,21 @@ class RSFormViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.AllowAny]
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'])
def claim(self, request, pk=None):
schema: models.RSForm = self.get_object()

View File

@ -8,9 +8,8 @@ from django.conf.urls.static import static
urlpatterns = [
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('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)

View File

@ -25,6 +25,7 @@
"react-loader-spinner": "^5.3.4",
"react-router-dom": "^6.12.1",
"react-scripts": "^5.0.1",
"react-select": "^5.7.4",
"react-tabs": "^6.0.1",
"react-toastify": "^9.1.3",
"styled-components": "^6.0.4",
@ -2369,6 +2370,70 @@
"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": {
"version": "1.2.1",
"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",
"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": {
"version": "0.8.5",
"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",
"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": {
"version": "4.4.0",
"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_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": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.17.0.tgz",
@ -4330,6 +4466,14 @@
"@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": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@ -7008,6 +7152,15 @@
"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": {
"version": "1.4.1",
"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"
}
},
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -12261,6 +12419,11 @@
"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": {
"version": "1.0.1",
"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",
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-6.0.1.tgz",
@ -14999,6 +15182,21 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -16923,6 +17121,19 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -20,6 +20,7 @@
"react-loader-spinner": "^5.3.4",
"react-router-dom": "^6.12.1",
"react-scripts": "^5.0.1",
"react-select": "^5.7.4",
"react-tabs": "^6.0.1",
"react-toastify": "^9.1.3",
"styled-components": "^6.0.4",

View File

@ -8,35 +8,38 @@ interface BackendErrorProps {
}
function DescribeError(error: ErrorInfo) {
console.log(error);
if (!error) {
return <p>Ошибки отсутствуют</p>;
} else if (typeof error === 'string') {
return <p>{error}</p>;
} 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 {
} else if (!axios.isAxiosError(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) {

View File

@ -26,6 +26,11 @@ function Modal({title, show, toggle, onSubmit, onCancel, canSubmit, children, su
if(onCancel) onCancel();
};
const handleSubmit = () => {
toggle();
onSubmit();
};
return (
<>
<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'
colorClass='clr-btn-primary'
disabled={!canSubmit}
onClick={onSubmit}
onClick={handleSubmit}
/>
<Button
text='Отмена'

View File

@ -1,21 +1,16 @@
import { InputHTMLAttributes } from 'react';
import Label from './Label';
interface TextInputProps {
interface TextInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
id: string
type: string
label: string
required?: boolean
disabled?: boolean
placeholder?: string
widthClass?: string
value?: any
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
onFocus?: () => void
}
function TextInput({
id, type, required, label, disabled, placeholder, widthClass='w-full', value,
onChange, onFocus
id, required, label, widthClass='w-full',
...props
}: TextInputProps) {
return (
<div className='flex flex-col items-start [&:not(:first-child)]:mt-3'>
@ -27,12 +22,7 @@ function TextInput({
<input id={id}
className={'px-3 py-2 mt-2 leading-tight border shadow dark:bg-gray-800 truncate hover:text-clip '+ widthClass}
required={required}
type={type}
placeholder={placeholder}
value={value}
onChange={onChange}
onFocus={onFocus}
disabled={disabled}
{...props}
/>
</div>
);

View File

@ -3,7 +3,7 @@ import { IConstituenta, IRSForm } from '../utils/models';
import { useRSFormDetails } from '../hooks/useRSFormDetails';
import { ErrorInfo } from '../components/BackendError';
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';
interface IRSFormContext {
@ -30,6 +30,7 @@ interface IRSFormContext {
download: (callback: BackendCallback) => void
cstUpdate: (data: any, callback: BackendCallback) => void
cstCreate: (data: any, callback: BackendCallback) => void
}
export const RSFormContext = createContext<IRSFormContext>({
@ -56,16 +57,17 @@ export const RSFormContext = createContext<IRSFormContext>({
download: () => {},
cstUpdate: () => {},
cstCreate: () => {},
})
interface RSFormStateProps {
id: string
schemaID: string
children: React.ReactNode
}
export const RSFormState = ({ id, children }: RSFormStateProps) => {
export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
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 [active, setActive] = useState<IConstituenta | undefined>(undefined);
@ -91,7 +93,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
async function update(data: any, callback?: BackendCallback) {
setError(undefined);
patchRSForm(id, {
patchRSForm(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
@ -102,7 +104,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
async function destroy(callback: BackendCallback) {
setError(undefined);
deleteRSForm(id, {
deleteRSForm(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
@ -112,7 +114,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
async function claim(callback: BackendCallback) {
setError(undefined);
postClaimRSForm(id, {
postClaimRSForm(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => setError(error),
@ -122,7 +124,7 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
async function download(callback: BackendCallback) {
setError(undefined);
getTRSFile(id, {
getTRSFile(schemaID, {
showError: true,
setLoading: setProcessing,
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 (
<RSFormContext.Provider value={{
schema, error, loading, processing,
@ -150,8 +163,8 @@ export const RSFormState = ({ id, children }: RSFormStateProps) => {
toggleReadonly: () => setReadonly(prev => !prev),
isOwned, isEditable, isClaimable,
isTracking, toggleTracking,
cstUpdate,
reload, update, download, destroy, claim
reload, update, download, destroy, claim,
cstUpdate, cstCreate
}}>
{ children }
</RSFormContext.Provider>

View File

@ -44,6 +44,7 @@ function LoginPage() {
required
type='text'
value={username}
autoFocus
onChange={event => setUsername(event.target.value)}
/>
<TextInput id='password'

View File

@ -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 DataTableThemed, { SelectionInfo } from '../../components/Common/DataTableThemed';
import DataTableThemed from '../../components/Common/DataTableThemed';
import { useRSForm } from '../../context/RSFormContext';
import Button from '../../components/Common/Button';
import { ArrowDownIcon, ArrowUpIcon, ArrowsRotateIcon, DumpBinIcon, SmallPlusIcon } from '../../components/Icons';
import { toast } from 'react-toastify';
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 { AxiosResponse } from 'axios';
interface ConstituentsTableProps {
onOpenEdit: (cst: IConstituenta) => void
}
function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
const { schema, isEditable, } = useRSForm();
const { schema, isEditable, cstCreate, reload } = useRSForm();
const [selectedRows, setSelectedRows] = useState<IConstituenta[]>([]);
const nothingSelected = useMemo(() => selectedRows.length === 0, [selectedRows]);
const [showCstModal, setShowCstModal] = useState(true);
const handleRowSelected = useCallback(
({selectedRows} : SelectionInfo<IConstituenta>) => {
setSelectedRows(selectedRows);
}, []);
const [showCstModal, setShowCstModal] = useState(false);
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
@ -48,13 +44,23 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
toast.info('Переиндексация');
}, []);
const handleAddNew = useCallback((cstType?: CstType) => {
if (!cstType) {
const handleAddNew = useCallback((csttype?: CstType) => {
if (!csttype) {
setShowCstModal(true);
} 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(() =>
[
@ -228,19 +234,20 @@ function ConstituentsTable({onOpenEdit}: ConstituentsTableProps) {
data={schema!.items!}
columns={columns}
keyField='id'
noDataComponent={<span className='p-2 flex flex-col justify-center text-center'>
<p>Список пуст</p>
<p>Создайте новую конституенту</p>
</span>}
noDataComponent={
<span className='flex flex-col justify-center p-2 text-center'>
<p>Список пуст</p>
<p>Создайте новую конституенту</p>
</span>
}
striped
highlightOnHover
pointerOnHover
selectableRows
// selectableRowSelected={(cst) => selectedRows.indexOf(cst) < -1}
selectableRowsHighlight
onSelectedRowsChange={handleRowSelected}
onSelectedRowsChange={({selectedRows}) => setSelectedRows(selectedRows)}
onRowDoubleClicked={onOpenEdit}
onRowClicked={handleRowClicked}
dense

View File

@ -1,7 +1,8 @@
import { toast } from 'react-toastify';
import Modal from '../../components/Common/Modal';
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 {
show: boolean
@ -11,11 +12,17 @@ interface CreateCstModalProps {
function CreateCstModal({show, toggle, onCreate}: CreateCstModalProps) {
const [validated, setValidated] = useState(false);
const [selectedType, setSelectedType] = useState<CstType|undefined>(undefined);
const handleSubmit = () => {
toast.info('Создание конституент');
if (selectedType) onCreate(selectedType);
};
useEffect(() => {
setValidated(selectedType !== undefined);
}, [selectedType]
);
return (
<Modal
title='Создание конституенты'
@ -24,8 +31,11 @@ function CreateCstModal({show, toggle, onCreate}: CreateCstModalProps) {
canSubmit={validated}
onSubmit={handleSubmit}
>
<p>Выбор типа конституенты</p>
<p>Добавить после выбранной или в конец</p>
<Select
options={CstTypeSelector}
placeholder='Выберите тип'
onChange={(data) => setSelectedType(data?.value)}
/>
</Modal>
)
}

View File

@ -5,7 +5,7 @@ import RSFormTabs from './RSFormTabs';
function RSFormPage() {
const { id } = useParams();
return (
<RSFormState id={id || ''}>
<RSFormState schemaID={id || ''}>
<RSFormTabs />
</RSFormState>
);

View File

@ -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 ===========
function AxiosGet<ReturnType>({endpoint, request, title}: IAxiosRequest) {

View File

@ -32,6 +32,13 @@ export interface IUserSignupData {
password2: string
}
// User data for signup
export interface INewCstData {
alias: string
csttype: CstType
insert_after?: number
}
// Constituenta type
export enum CstType {
BASE = 'basic',

View File

@ -1,4 +1,4 @@
import { CstType, ExpressionStatus, IConstituenta, ParsingStatus, TokenID } from './models';
import { CstType, ExpressionStatus, IConstituenta, IRSForm, ParsingStatus, TokenID } from './models';
export interface IRSButtonData {
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) {
switch(type) {
case CstType.BASE: return 'X';
@ -250,4 +256,20 @@ export function getStatusInfo(status?: ExpressionStatus): IStatusInfo {
export function extractGlobals(expression: string): Set<string> {
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;
}