Implement RSForm upload and fix bugs

This commit is contained in:
IRBorisov 2023-07-27 22:04:25 +03:00
parent 467f45b0c8
commit 9dd3155319
26 changed files with 508 additions and 176 deletions

View File

@ -9,4 +9,4 @@ $exclude = '*/venv/*,*/tests/*,*/migrations/*,*__init__.py,manage.py,apps.py,url
& $coverageExec report
& $coverageExec html
Start-Process "file:///D:/DEV/!WORK/Concept-Web/rsconcept/backend/htmlcov/index.html"
Start-Process "file:///$PSScriptRoot\backend\htmlcov\index.html"

View File

@ -11,7 +11,7 @@ def load_initial_schemas(apps, schema_editor):
for subdir, dirs, files in os.walk(rootdir):
for file in files:
data = utils.read_trs(os.path.join(subdir, file))
RSForm.import_json(None, data)
RSForm.create_from_trs(None, data)
def load_initial_users(apps, schema_editor):

View File

@ -98,7 +98,7 @@ class RSForm(models.Model):
''' Insert new constituenta at last position '''
position = 1
if self.constituents().exists():
position += self.constituents().only('order').aggregate(models.Max('order'))['order__max']
position += self.constituents().count()
result = Constituenta.objects.create(
schema=self,
order=position,
@ -118,7 +118,7 @@ class RSForm(models.Model):
count_bot = 0
size = len(listCst)
update_list = []
for cst in self.constituents():
for cst in self.constituents().only('id', 'order').order_by('order'):
if cst not in listCst:
if count_top + 1 < target:
cst.order = count_top + 1
@ -142,9 +142,38 @@ class RSForm(models.Model):
self._update_from_core()
self.save()
@transaction.atomic
def load_trs(self, data: dict, sync_metadata: bool, skip_update: bool):
if sync_metadata:
self.title = data.get('title', 'Без названия')
self.alias = data.get('alias', '')
self.comment = data.get('comment', '')
order = 1
prev_constituents = self.constituents()
loaded_ids = set()
for cst_data in data['items']:
uid = int(cst_data['entityUID'])
if prev_constituents.filter(pk=uid).exists():
cst: Constituenta = prev_constituents.get(pk=uid)
cst.order = order
cst.load_trs(cst_data)
cst.save()
else:
cst = Constituenta.create_from_trs(cst_data, self, order)
cst.save()
uid = cst.id
loaded_ids.add(uid)
order += 1
for prev_cst in prev_constituents:
if prev_cst.id not in loaded_ids:
prev_cst.delete()
if not skip_update:
self._update_from_core()
self.save()
@staticmethod
@transaction.atomic
def import_json(owner: User, data: dict, is_common: bool = True) -> 'RSForm':
def create_from_trs(owner: User, data: dict, is_common: bool = True) -> 'RSForm':
schema = RSForm.objects.create(
title=data.get('title', 'Без названия'),
owner=owner,
@ -152,15 +181,15 @@ class RSForm(models.Model):
comment=data.get('comment', ''),
is_common=is_common
)
schema._create_cst_from_json(data['items'])
schema._create_items_from_trs(data['items'])
return schema
def to_json(self) -> str:
def to_trs(self) -> str:
''' Generate JSON string containing all data from RSForm '''
result = self._prepare_json_rsform()
items = self.constituents().order_by('order')
items: list['Constituenta'] = self.constituents().order_by('order')
for cst in items:
result['items'].append(cst.to_json())
result['items'].append(cst.to_trs())
return result
def __str__(self):
@ -178,8 +207,9 @@ class RSForm(models.Model):
'items': []
}
@transaction.atomic
def _update_from_core(self) -> dict:
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_json())))
checked = json.loads(pyconcept.check_schema(json.dumps(self.to_trs())))
update_list = self.constituents().only('id', 'order')
if (len(checked['items']) != update_list.count()):
raise ValidationError
@ -194,10 +224,12 @@ class RSForm(models.Model):
Constituenta.objects.bulk_update(update_list, ['order'])
return checked
def _create_cst_from_json(self, items):
@transaction.atomic
def _create_items_from_trs(self, items):
order = 1
for cst in items:
Constituenta.import_json(cst, self, order)
object = Constituenta.create_from_trs(cst, self, order)
object.save()
order += 1
@ -270,28 +302,43 @@ class Constituenta(models.Model):
return self.alias
@staticmethod
def import_json(data: dict, schema: RSForm, order: int) -> 'Constituenta':
def create_from_trs(data: dict, schema: RSForm, order: int) -> 'Constituenta':
''' Create constituenta from TRS json '''
cst = Constituenta(
alias=data['alias'],
schema=schema,
order=order,
cst_type=data['cstType'],
convention=data.get('convention', 'Без названия')
)
if 'definition' in data:
if 'formal' in data['definition']:
cst.definition_formal = data['definition']['formal']
if 'text' in data['definition']:
cst.definition_raw = data['definition']['text'].get('raw', '')
cst.definition_resolved = data['definition']['text'].get('resolved', '')
if 'term' in data:
cst.term_raw = data['term'].get('raw', '')
cst.term_resolved = data['term'].get('resolved', '')
cst.term_forms = data['term'].get('forms', [])
cst.save()
cst._load_texts(data)
return cst
def to_json(self) -> str:
def load_trs(self, data: dict):
''' Load data from TRS json '''
self.alias = data['alias']
self.cst_type = data['cstType']
self._load_texts(data)
def _load_texts(self, data: dict):
self.convention = data.get('convention', '')
if 'definition' in data:
self.definition_formal = data['definition'].get('formal', '')
if 'text' in data['definition']:
self.definition_raw = data['definition']['text'].get('raw', '')
self.definition_resolved = data['definition']['text'].get('resolved', '')
else:
self.definition_raw = ''
self.definition_resolved = ''
if 'term' in data:
self.term_raw = data['term'].get('raw', '')
self.term_resolved = data['term'].get('resolved', '')
self.term_forms = data['term'].get('forms', [])
else:
self.term_raw = ''
self.term_resolved = ''
self.term_forms = []
def to_trs(self) -> str:
return {
'entityUID': self.id,
'type': 'constituenta',

View File

@ -20,6 +20,23 @@ class RSFormSerializer(serializers.ModelSerializer):
read_only_fields = ('owner', 'id')
class RSFormUploadSerializer(serializers.Serializer):
file = serializers.FileField()
load_metadata = serializers.BooleanField()
class RSFormContentsSerializer(serializers.ModelSerializer):
class Meta:
model = RSForm
def to_representation(self, instance: RSForm):
result = RSFormSerializer(instance).data
result['items'] = []
for cst in instance.constituents().order_by('order'):
result['items'].append(ConstituentaSerializer(cst).data)
return result
class ConstituentaSerializer(serializers.ModelSerializer):
class Meta:
model = Constituenta
@ -79,7 +96,7 @@ class RSFormDetailsSerlializer(serializers.BaseSerializer):
model = RSForm
def to_representation(self, instance: RSForm):
trs = pyconcept.check_schema(json.dumps(instance.to_json()))
trs = pyconcept.check_schema(json.dumps(instance.to_trs()))
trs = trs.replace('entityUID', 'id')
result = json.loads(trs)
result['id'] = instance.id

View File

@ -209,7 +209,7 @@ class TestRSForm(TestCase):
self.assertEqual(x1.order, 2)
self.assertEqual(x2.order, 1)
def test_to_json(self):
def test_to_trs(self):
schema = RSForm.objects.create(title='Test', alias='KS1', comment='Test')
x1 = schema.insert_at(4, 'X1', CstType.BASE)
x2 = schema.insert_at(1, 'X2', CstType.BASE)
@ -223,9 +223,9 @@ class TestRSForm(TestCase):
f'"term": {{"raw": "", "resolved": "", "forms": []}}, '
f'"definition": {{"formal": "", "text": {{"raw": "", "resolved": ""}}}}}}]}}'
)
self.assertEqual(schema.to_json(), expected)
self.assertEqual(schema.to_trs(), expected)
def test_import_json(self):
def test_create_from_trs(self):
input = json.loads(
'{"type": "rsform", "title": "Test", "alias": "KS1", '
'"comment": "Test", "items": '
@ -236,7 +236,7 @@ class TestRSForm(TestCase):
'"term": {"raw": "", "resolved": ""}, '
'"definition": {"formal": "", "text": {"raw": "", "resolved": ""}}}]}'
)
schema = RSForm.import_json(self.user1, input, False)
schema = RSForm.create_from_trs(self.user1, input, False)
self.assertEqual(schema.owner, self.user1)
self.assertEqual(schema.title, 'Test')
self.assertEqual(schema.alias, 'KS1')
@ -245,3 +245,28 @@ class TestRSForm(TestCase):
self.assertEqual(constituents.count(), 2)
self.assertEqual(constituents[0].alias, 'X1')
self.assertEqual(constituents[0].definition_formal, '123')
def test_load_trs(self):
schema = RSForm.objects.create(title='Test', owner=self.user1, alias='КС1')
x2 = schema.insert_last('X2', CstType.BASE)
schema.insert_last('X3', CstType.BASE)
input = json.loads(
'{"title": "Test1", "alias": "KS1", '
'"comment": "Test", "items": '
'[{"entityUID": "' + str(x2.id) + '", "cstType": "basic", "alias": "X1", "convention": "test", '
'"term": {"raw": "t1", "resolved": "t2"}, '
'"definition": {"formal": "123", "text": {"raw": "t3", "resolved": "t4"}}}]}'
)
schema.load_trs(input, sync_metadata=True, skip_update=True)
x2.refresh_from_db()
self.assertEqual(schema.constituents().count(), 1)
self.assertEqual(schema.title, input['title'])
self.assertEqual(schema.alias, input['alias'])
self.assertEqual(schema.comment, input['comment'])
self.assertEqual(x2.alias, input['items'][0]['alias'])
self.assertEqual(x2.convention, input['items'][0]['convention'])
self.assertEqual(x2.term_raw, input['items'][0]['term']['raw'])
self.assertEqual(x2.term_resolved, input['items'][0]['term']['resolved'])
self.assertEqual(x2.definition_formal, input['items'][0]['definition']['formal'])
self.assertEqual(x2.definition_raw, input['items'][0]['definition']['text']['raw'])
self.assertEqual(x2.definition_resolved, input['items'][0]['definition']['text']['resolved'])

View File

@ -115,7 +115,6 @@ class TestRSFormViewset(APITestCase):
schema.insert_last(alias='X1', type=CstType.BASE)
response = self.client.get(f'/api/rsforms/{schema.id}/contents/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, schema.to_json())
def test_details(self):
schema = RSForm.objects.create(title='Test')
@ -248,6 +247,57 @@ class TestRSFormViewset(APITestCase):
data=data, content_type='application/json')
self.assertEqual(response.status_code, 400)
def test_reset_aliases(self):
schema = self.rsform_owned
response = self.client.patch(f'/api/rsforms/{schema.id}/reset-aliases/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['id'], schema.id)
x2 = Constituenta.objects.create(schema=schema, alias='X2', cst_type='basic', order=1)
x1 = Constituenta.objects.create(schema=schema, alias='X1', cst_type='basic', order=2)
d11 = Constituenta.objects.create(schema=schema, alias='D11', cst_type='term', order=3)
response = self.client.patch(f'/api/rsforms/{schema.id}/reset-aliases/')
x1.refresh_from_db()
x2.refresh_from_db()
d11.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(x2.order, 1)
self.assertEqual(x2.alias, 'X1')
self.assertEqual(x1.order, 2)
self.assertEqual(x1.alias, 'X2')
self.assertEqual(d11.order, 3)
self.assertEqual(d11.alias, 'D1')
response = self.client.patch(f'/api/rsforms/{schema.id}/reset-aliases/')
self.assertEqual(response.status_code, 200)
def test_load_trs(self):
schema = self.rsform_owned
schema.title = 'Testt11'
schema.save()
x1 = Constituenta.objects.create(schema=schema, alias='X1', cst_type='basic', order=1)
work_dir = os.path.dirname(os.path.abspath(__file__))
with open(f'{work_dir}/data/sample-rsform.trs', 'rb') as file:
data = {'file': file, 'load_metadata': False}
response = self.client.patch(f'/api/rsforms/{schema.id}/load-trs/', data=data, format='multipart')
schema.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(schema.title, 'Testt11')
self.assertEqual(len(response.data['items']), 25)
self.assertEqual(schema.constituents().count(), 25)
self.assertFalse(Constituenta.objects.all().filter(pk=x1.id).exists())
def test_clone(self):
schema = self.rsform_owned
schema.title = 'Testt11'
schema.save()
x1 = Constituenta.objects.create(schema=schema, alias='X12', cst_type='basic', order=1)
data = json.dumps({'title': 'Title'})
response = self.client.post(f'/api/rsforms/{schema.id}/clone/', data=data, content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'Title')
self.assertEqual(response.data['items'][0]['alias'], x1.alias)
class TestFunctionalViews(APITestCase):
def setUp(self):

View File

@ -34,6 +34,9 @@ class RSFormViewSet(viewsets.ModelViewSet):
ordering_fields = ('owner', 'title', 'time_update')
ordering = ('-time_update')
def _get_schema(self) -> models.RSForm:
return self.get_object()
def perform_create(self, serializer):
if not self.request.user.is_anonymous and 'owner' not in self.request.POST:
return serializer.save(owner=self.request.user)
@ -41,10 +44,10 @@ class RSFormViewSet(viewsets.ModelViewSet):
return serializer.save()
def get_permissions(self):
if self.action in ['update', 'destroy', 'partial_update',
'cst_create', 'cst_multidelete']:
if self.action in ['update', 'destroy', 'partial_update', 'load_trs',
'cst_create', 'cst_multidelete', 'reset_aliases']:
permission_classes = [utils.ObjectOwnerOrAdmin]
elif self.action in ['create', 'claim']:
elif self.action in ['create', 'claim', 'clone']:
permission_classes = [permissions.IsAuthenticated]
else:
permission_classes = [permissions.AllowAny]
@ -53,7 +56,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['post'], url_path='cst-create')
def cst_create(self, request, pk):
''' Create new constituenta '''
schema: models.RSForm = self.get_object()
schema = self._get_schema()
serializer = serializers.CstCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if ('insert_after' in serializer.validated_data and serializer.validated_data['insert_after'] is not None):
@ -74,7 +77,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['patch'], url_path='cst-multidelete')
def cst_multidelete(self, request, pk):
''' Delete multiple constituents '''
schema: models.RSForm = self.get_object()
schema = self._get_schema()
serializer = serializers.CstListSerlializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True)
schema.delete_cst(serializer.validated_data['constituents'])
@ -85,7 +88,7 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['patch'], url_path='cst-moveto')
def cst_moveto(self, request, pk):
''' Delete multiple constituents '''
schema: models.RSForm = self.get_object()
schema: models.RSForm = self._get_schema()
serializer = serializers.CstMoveSerlializer(data=request.data, context={'schema': schema})
serializer.is_valid(raise_exception=True)
schema.move_cst(serializer.validated_data['constituents'], serializer.validated_data['move_to'])
@ -93,9 +96,46 @@ class RSFormViewSet(viewsets.ModelViewSet):
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='reset-aliases')
def reset_aliases(self, request, pk):
''' Recreate all aliases based on order '''
schema = self._get_schema()
result = json.loads(pyconcept.reset_aliases(json.dumps(schema.to_trs())))
schema.load_trs(data=result, sync_metadata=False, skip_update=True)
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['patch'], url_path='load-trs')
def load_trs(self, request, pk):
''' Load data from file and replace current schema '''
serializer = serializers.RSFormUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
schema = self._get_schema()
load_metadata = serializer.validated_data['load_metadata']
data = utils.read_trs(request.FILES['file'].file)
schema.load_trs(data, load_metadata, skip_update=False)
outSerializer = serializers.RSFormDetailsSerlializer(schema)
return Response(status=200, data=outSerializer.data)
@action(detail=True, methods=['post'], url_path='clone')
def clone(self, request, pk):
''' Clone RSForm constituents and create new schema using new metadata '''
serializer = serializers.RSFormSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
new_schema = models.RSForm.objects.create(
title=serializer.validated_data['title'],
owner=self.request.user,
alias=serializer.validated_data.get('alias', ''),
comment=serializer.validated_data.get('comment', ''),
is_common=serializer.validated_data.get('is_common', False),
)
new_schema.load_trs(data=self._get_schema().to_trs(), sync_metadata=False, skip_update=True)
outSerializer = serializers.RSFormDetailsSerlializer(new_schema)
return Response(status=201, data=outSerializer.data)
@action(detail=True, methods=['post'])
def claim(self, request, pk=None):
schema: models.RSForm = self.get_object()
schema = self._get_schema()
if schema.owner == self.request.user:
return Response(status=304)
else:
@ -105,21 +145,21 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'])
def contents(self, request, pk):
''' View schema contents (including constituents) '''
schema = self.get_object().to_json()
''' View schema db contents (including constituents) '''
schema = serializers.RSFormContentsSerializer(self._get_schema()).data
return Response(schema)
@action(detail=True, methods=['get'])
def details(self, request, pk):
''' Detailed schema view including statuses '''
schema: models.RSForm = self.get_object()
schema = self._get_schema()
serializer = serializers.RSFormDetailsSerlializer(schema)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def check(self, request, pk):
''' Check RS expression against schema context '''
schema = self.get_object().to_json()
schema = self._get_schema().to_trs()
serializer = serializers.ExpressionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
expression = serializer.validated_data['expression']
@ -129,9 +169,9 @@ class RSFormViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'], url_path='export-trs')
def export_trs(self, request, pk):
''' Download Exteor compatible file '''
schema = self.get_object().to_json()
schema = self._get_schema().to_trs()
trs = utils.write_trs(schema)
filename = self.get_object().alias
filename = self._get_schema().alias
if filename == '' or not filename.isascii():
# Note: non-ascii symbols in Content-Disposition
# are not supported by some browsers
@ -152,7 +192,7 @@ class TrsImportView(views.APIView):
owner = self.request.user
if owner.is_anonymous:
owner = None
schema = models.RSForm.import_json(owner, data)
schema = models.RSForm.create_from_trs(owner, data)
result = serializers.RSFormSerializer(schema)
return Response(status=201, data=result.data)
@ -167,11 +207,11 @@ def create_rsform(request):
serializer = serializers.RSFormSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
schema = models.RSForm.objects.create(
title=request.data['title'],
title=serializer.validated_data['title'],
owner=owner,
alias=request.data.get('alias', ''),
comment=request.data.get('comment', ''),
is_common=request.data.get('is_common', False),
alias=serializer.validated_data.get('alias', ''),
comment=serializer.validated_data.get('comment', ''),
is_common=serializer.validated_data.get('is_common', False),
)
else:
data = utils.read_trs(request.FILES['file'].file)
@ -186,7 +226,7 @@ def create_rsform(request):
is_common = True
if ('is_common' in request.data):
is_common = request.data['is_common'] == 'true'
schema = models.RSForm.import_json(owner, data, is_common)
schema = models.RSForm.create_from_trs(owner, data, is_common)
result = serializers.RSFormSerializer(schema)
return Response(status=201, data=result.data)

View File

@ -21,17 +21,13 @@ class LoginSerializer(serializers.Serializer):
def validate(self, attrs):
username = attrs.get('username')
password = attrs.get('password')
if username and password:
user = authenticate(
request=self.context.get('request'),
username=username,
password=password
)
if not user:
msg = 'Неправильное сочетание имени пользователя и пароля.'
raise serializers.ValidationError(msg, code='authorization')
else:
msg = 'Заполните оба поля: Имя пользователя и Пароль.'
user = authenticate(
request=self.context.get('request'),
username=username,
password=password
)
if not user:
msg = 'Неправильное сочетание имени пользователя и пароля.'
raise serializers.ValidationError(msg, code='authorization')
attrs['user'] = user
return attrs

View File

@ -29,3 +29,9 @@ class TestLoginSerializer(APITestCase):
request = self.factory.post('/users/api/login', data)
serializer = LoginSerializer(data=data, context={'request': request})
self.assertFalse(serializer.is_valid(raise_exception=False))
def test_validate_empty_username(self):
data = {'username': '', 'auth': 'invalid'}
request = self.factory.post('/users/api/login', data)
serializer = LoginSerializer(data=data, context={'request': request})
self.assertFalse(serializer.is_valid(raise_exception=False))

View File

@ -1,5 +1,5 @@
interface ButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children'> {
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children' | 'title'> {
text?: string
icon?: React.ReactNode
tooltip?: string

View File

@ -5,7 +5,7 @@ import Button from './Button';
import Label from './Label';
interface FileInputProps {
id: string
id?: string
required?: boolean
label: string
acceptType?: string
@ -33,7 +33,7 @@ function FileInput({ id, required, label, acceptType, widthClass = 'w-full', onC
};
return (
<div className={'flex gap-2 py-2 mt-3 items-center ' + widthClass}>
<div className={'flex flex-col gap-2 py-2 [&:not(:first-child)]:mt-3 items-start ' + widthClass}>
<input id={id} type='file'
ref={inputRef}
required={required}

View File

@ -0,0 +1,20 @@
interface MiniButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'title' > {
icon?: React.ReactNode
tooltip?: string
}
function MiniButton({ icon, tooltip, children, ...props }: MiniButtonProps) {
return (
<button type='button'
title={tooltip}
className='px-1 py-1 font-bold rounded-full cursor-pointer whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear'
{...props}
>
{icon && <span>{icon}</span>}
{children}
</button>
);
}
export default MiniButton;

View File

@ -39,7 +39,7 @@ function Modal({ title, show, hideWindow, onSubmit, onCancel, canSubmit, childre
<div className='fixed top-0 left-0 z-50 w-full h-full opacity-50 clr-modal'>
</div>
<div ref={ref} className='fixed bottom-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 px-6 py-4 flex flex-col w-fit z-[60] clr-card border shadow-md'>
{ title && <h1 className='mb-4 text-xl font-bold'>{title}</h1> }
{ title && <h1 className='mb-4 text-xl font-bold text-center'>{title}</h1> }
<div className='py-2'>
{children}
</div>

View File

@ -5,12 +5,12 @@ import { type ErrorInfo } from '../components/BackendError'
import { useRSFormDetails } from '../hooks/useRSFormDetails'
import {
type DataCallback, deleteRSForm, getTRSFile,
patchConstituenta, patchDeleteConstituenta, patchMoveConstituenta, patchRSForm,
postClaimRSForm, postNewConstituenta
} from '../utils/backendAPI'
patchConstituenta, patchDeleteConstituenta, patchMoveConstituenta, patchResetAliases,
patchRSForm,
patchUploadTRS, postClaimRSForm, postNewConstituenta} from '../utils/backendAPI'
import {
IConstituenta, IConstituentaList, IConstituentaMeta, ICstCreateData,
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormMeta, IRSFormUpdateData
ICstMovetoData, ICstUpdateData, IRSForm, IRSFormMeta, IRSFormUpdateData, IRSFormUploadData
} from '../utils/models'
import { useAuth } from './AuthContext'
@ -36,9 +36,11 @@ interface IRSFormContext {
toggleTracking: () => void
update: (data: IRSFormUpdateData, callback?: DataCallback<IRSFormMeta>) => void
destroy: (callback?: DataCallback) => void
destroy: (callback?: () => void) => void
claim: (callback?: DataCallback<IRSFormMeta>) => void
download: (callback: DataCallback<Blob>) => void
upload: (data: IRSFormUploadData, callback: () => void) => void
resetAliases: (callback: () => void) => void
cstCreate: (data: ICstCreateData, callback?: DataCallback<IConstituentaMeta>) => void
cstUpdate: (data: ICstUpdateData, callback?: DataCallback<IConstituentaMeta>) => void
@ -114,16 +116,34 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
});
}, [schemaID, setError, setSchema, schema])
const upload = useCallback(
(data: IRSFormUploadData, callback?: () => void) => {
if (!schema) {
return;
}
setError(undefined)
patchUploadTRS(schemaID, {
data: data,
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(newData);
if (callback) callback();
}
});
}, [schemaID, setError, setSchema, schema])
const destroy = useCallback(
(callback?: DataCallback) => {
(callback?: () => void) => {
setError(undefined)
deleteRSForm(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
onSuccess: () => {
setSchema(undefined);
if (callback) callback(newData);
if (callback) callback();
}
});
}, [schemaID, setError, setSchema])
@ -145,6 +165,23 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
});
}, [schemaID, setError, schema, user, setSchema])
const resetAliases = useCallback(
(callback?: () => void) => {
if (!schema || !user) {
return;
}
setError(undefined)
patchResetAliases(schemaID, {
showError: true,
setLoading: setProcessing,
onError: error => { setError(error) },
onSuccess: newData => {
setSchema(Object.assign(schema, newData));
if (callback) callback();
}
});
}, [schemaID, setError, schema, user, setSchema])
const download = useCallback(
(callback: DataCallback<Blob>) => {
setError(undefined)
@ -218,29 +255,15 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
return (
<RSFormContext.Provider value={{
schema,
error,
loading,
processing,
activeID,
activeCst,
setActiveID,
isForceAdmin,
isReadonly,
error, loading, processing,
activeID, activeCst, setActiveID,
isForceAdmin, isReadonly, isOwned, isEditable,
isClaimable, isTracking,
toggleForceAdmin: () => { setIsForceAdmin(prev => !prev) },
toggleReadonly: () => { setIsReadonly(prev => !prev) },
isOwned,
isEditable,
isClaimable,
isTracking,
toggleTracking,
update,
download,
destroy,
claim,
cstUpdate,
cstCreate,
cstDelete,
cstMoveTo
update, download, upload, destroy, claim, resetAliases,
cstUpdate, cstCreate, cstDelete, cstMoveTo
}}>
{ children }
</RSFormContext.Provider>

View File

@ -69,7 +69,7 @@
/* Transparent button */
.clr-btn-clear {
@apply hover:bg-gray-300 dark:hover:bg-gray-400
@apply hover:bg-gray-300 dark:hover:bg-gray-400 dark:disabled:text-zinc-400 disabled:text-gray-400 text-gray-500 dark:text-zinc-200
}
.clr-checkbox {

View File

@ -53,7 +53,7 @@ function RSFormCreatePage() {
file: file,
fileName: file?.name
};
void createSchema(data, onSuccess);
createSchema(data, onSuccess);
};
return (

View File

@ -2,6 +2,7 @@ import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import MiniButton from '../../components/Common/MiniButton';
import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea';
import { DumpBinIcon, SaveIcon, SmallPlusIcon } from '../../components/Icons';
@ -11,7 +12,7 @@ import { createAliasFor, getCstTypeLabel } from '../../utils/staticUI';
import ConstituentsSideList from './ConstituentsSideList';
import CreateCstModal from './CreateCstModal';
import ExpressionEditor from './ExpressionEditor';
import { RSFormTabsList } from './RSFormTabs';
import { RSTabsList } from './RSTabs';
function ConstituentEditor() {
const navigate = useNavigate();
@ -113,7 +114,7 @@ function ConstituentEditor() {
insert_after: activeID
}
cstCreate(data, newCst => {
navigate(`/rsforms/${schema.id}?tab=${RSFormTabsList.CST_EDIT}&active=${newCst.id}`);
navigate(`/rsforms/${schema.id}?tab=${RSTabsList.CST_EDIT}&active=${newCst.id}`);
toast.success(`Конституента добавлена: ${newCst.alias}`);
});
}
@ -167,22 +168,18 @@ function ConstituentEditor() {
</span>
</div>
<div className='flex justify-end'>
<button type='button'
title='Создать конституенты после данной'
className='px-1 py-1 font-bold rounded-full whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear'
<MiniButton
tooltip='Создать конституенты после данной'
disabled={!isEnabled}
onClick={() => { handleAddNew(); }}
>
<SmallPlusIcon size={5} color={isEnabled ? 'text-green' : ''} />
</button>
<button type='button'
title='Удалить редактируемую конституенту'
className='px-1 py-1 font-bold rounded-full whitespace-nowrap disabled:cursor-not-allowed clr-btn-clear'
icon={<SmallPlusIcon size={5} color={isEnabled ? 'text-green' : ''} />}
/>
<MiniButton
tooltip='Удалить редактируемую конституенту'
disabled={!isEnabled}
onClick={handleDelete}
>
<DumpBinIcon size={5} color={isEnabled ? 'text-red' : ''} />
</button>
icon={<DumpBinIcon size={5} color={isEnabled ? 'text-red' : ''} />}
/>
</div>
</div>
<TextArea id='term' label='Термин'

View File

@ -18,13 +18,14 @@ interface ConstituentsTableProps {
function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
const {
schema, isEditable,
cstCreate, cstDelete, cstMoveTo
cstCreate, cstDelete, cstMoveTo, resetAliases
} = useRSForm();
const { noNavigation } = useConceptTheme();
const [selected, setSelected] = useState<number[]>([]);
const nothingSelected = useMemo(() => selected.length === 0, [selected]);
const [showCstModal, setShowCstModal] = useState(false);
const [toggledClearRows, setToggledClearRows] = useState(false);
const handleRowClicked = useCallback(
(cst: IConstituenta, event: React.MouseEvent<Element, MouseEvent>) => {
@ -39,8 +40,8 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
selectedCount: number
selectedRows: IConstituenta[]
}) => {
setSelected(selectedRows.map((cst) => cst.id));
}, [setSelected]);
setSelected(selectedRows.map(cst => cst.id));
}, [setSelected]);
// Delete selected constituents
const handleDelete = useCallback(() => {
@ -50,8 +51,11 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
const data = {
items: selected.map(id => { return { id }; })
}
const deletedNames = selected.map(id => schema.items?.find((cst) => cst.id === id)?.alias).join(', ');
cstDelete(data, () => toast.success(`Конституенты удалены: ${deletedNames}`));
const deletedNames = selected.map(id => schema.items?.find(cst => cst.id === id)?.alias).join(', ');
cstDelete(data, () => {
toast.success(`Конституенты удалены: ${deletedNames}`);
setToggledClearRows(prev => !prev);
});
}, [selected, schema?.items, cstDelete]);
// Move selected cst up
@ -70,7 +74,7 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
}, -1);
const target = Math.max(0, currentIndex - 1) + 1
const data = {
items: selected.map(id => { return { id }; }),
items: selected.map(id => { return { id: id }; }),
move_to: target
}
cstMoveTo(data);
@ -96,7 +100,7 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
}, -1);
const target = Math.min(schema.items.length - 1, currentIndex - count + 2) + 1
const data: ICstMovetoData = {
items: selected.map(id => { return { id }; }),
items: selected.map(id => { return { id: id }; }),
move_to: target
}
cstMoveTo(data);
@ -104,8 +108,8 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
// Generate new names for all constituents
const handleReindex = useCallback(() => {
toast.info('Переиндексация');
}, []);
resetAliases(() => toast.success('Переиндексация конституент успешна'));
}, [resetAliases]);
// Add new constituent
const handleAddNew = useCallback((type?: CstType) => {
@ -130,7 +134,15 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
// Implement hotkeys for working with constituents table
function handleTableKey(event: React.KeyboardEvent<HTMLDivElement>) {
if (!event.altKey || !isEditable || event.shiftKey) {
if (!isEditable) {
return;
}
if (event.key === 'Delete' && selected.length > 0) {
event.preventDefault();
handleDelete();
return;
}
if (!event.altKey || event.shiftKey) {
return;
}
if (processAltKey(event.key)) {
@ -335,7 +347,11 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
})}
</div>}
</div>
<div className='w-full h-full' onKeyDown={handleTableKey} tabIndex={0}>
<div className='w-full h-full'
onKeyDown={handleTableKey}
tabIndex={0}
title='Горячие клавиши:&#013;Двойной клик / Alt + клик - редактирование конституенты&#013;Alt + вверх/вниз - движение конституент&#013;Delete - удаление выбранных&#013;Alt + 1-6, Q,W - добавление конституент'
>
<DataTableThemed
data={schema?.items ?? []}
columns={columns}
@ -356,6 +372,7 @@ function ConstituentsTable({ onOpenEdit }: ConstituentsTableProps) {
onSelectedRowsChange={handleSelectionChange}
onRowDoubleClicked={onOpenEdit}
onRowClicked={handleRowClicked}
clearSelectedRows={toggledClearRows}
dense
/>
</div>

View File

@ -3,8 +3,8 @@ import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox';
import MiniButton from '../../components/Common/MiniButton';
import SubmitButton from '../../components/Common/SubmitButton';
import TextArea from '../../components/Common/TextArea';
import TextInput from '../../components/Common/TextInput';
@ -76,6 +76,32 @@ function RSFormCard() {
return (
<form onSubmit={handleSubmit} className='flex-grow max-w-xl px-4 py-2 border min-w-fit'>
<div className='relative w-full'>
<div className='absolute top-0 right-0'>
<MiniButton
tooltip='Поделиться схемой'
icon={<ShareIcon size={5} color='text-primary'/>}
onClick={shareCurrentURLProc}
/>
<MiniButton
tooltip='Скачать TRS файл'
icon={<DownloadIcon size={5} color='text-primary'/>}
onClick={handleDownload}
/>
<MiniButton
tooltip={isClaimable ? 'Стать владельцем' : 'Вы уже являетесь владельцем' }
icon={<CrownIcon size={5} color={isOwned ? '' : 'text-green'}/>}
disabled={!isClaimable || !user}
onClick={() => { claimOwnershipProc(claim); }}
/>
<MiniButton
tooltip='Удалить схему'
disabled={!isEditable}
onClick={handleDelete}
icon={<DumpBinIcon size={5} color={isEditable ? 'text-red' : ''} />}
/>
</div>
</div>
<TextInput id='title' label='Полное название' type='text'
required
value={title}
@ -107,33 +133,6 @@ function RSFormCard() {
disabled={!isModified || !isEditable}
icon={<SaveIcon size={6} />}
/>
<div className='flex justify-end gap-1'>
<Button
tooltip='Поделиться схемой'
icon={<ShareIcon color='text-primary'/>}
onClick={shareCurrentURLProc}
/>
<Button
tooltip='Скачать TRS файл'
icon={<DownloadIcon color='text-primary'/>}
loading={processing}
onClick={handleDownload}
/>
<Button
tooltip={isClaimable ? 'Стать владельцем' : 'Вы уже являетесь владельцем' }
icon={<CrownIcon color={isOwned ? '' : 'text-green'}/>}
loading={processing}
disabled={!isClaimable || !user}
onClick={() => { claimOwnershipProc(claim); }}
/>
<Button
tooltip={ isEditable ? 'Удалить схему' : 'Вы не можете редактировать данную схему'}
icon={<DumpBinIcon color={isEditable ? 'text-red' : ''} />}
loading={processing}
disabled={!isEditable}
onClick={handleDelete}
/>
</div>
</div>
<div className='flex justify-start mt-2'>

View File

@ -11,23 +11,23 @@ import ConstituentEditor from './ConstituentEditor';
import ConstituentsTable from './ConstituentsTable';
import RSFormCard from './RSFormCard';
import RSFormStats from './RSFormStats';
import TablistTools from './TablistTools';
import RSTabsMenu from './RSTabsMenu';
export enum RSFormTabsList {
export enum RSTabsList {
CARD = 0,
CST_LIST = 1,
CST_EDIT = 2
}
function RSFormTabs() {
function RSTabs() {
const { setActiveID, activeID, error, schema, loading } = useRSForm();
const [tabIndex, setTabIndex] = useLocalStorage('rsform_edit_tab', RSFormTabsList.CARD);
const [tabIndex, setTabIndex] = useLocalStorage('rsform_edit_tab', RSTabsList.CARD);
const [init, setInit] = useState(false);
const onEditCst = (cst: IConstituenta) => {
console.log(`Set active cst: ${cst.alias}`);
setActiveID(cst.id);
setTabIndex(RSFormTabsList.CST_EDIT)
setTabIndex(RSTabsList.CST_EDIT)
};
const onSelectTab = (index: number) => {
@ -47,7 +47,7 @@ function RSFormTabs() {
useEffect(() => {
const url = new URL(window.location.href);
const tabQuery = url.searchParams.get('tab');
setTabIndex(Number(tabQuery) || RSFormTabsList.CARD);
setTabIndex(Number(tabQuery) || RSTabsList.CARD);
}, [setTabIndex]);
useEffect(() => {
@ -55,7 +55,7 @@ function RSFormTabs() {
const url = new URL(window.location.href);
const currentActive = url.searchParams.get('active');
const currentTab = url.searchParams.get('tab');
const saveHistory = tabIndex === RSFormTabsList.CST_EDIT && currentActive !== String(activeID);
const saveHistory = tabIndex === RSTabsList.CST_EDIT && currentActive !== String(activeID);
if (currentTab !== String(tabIndex)) {
url.searchParams.set('tab', String(tabIndex));
}
@ -86,7 +86,7 @@ function RSFormTabs() {
selectedTabClassName='font-bold'
>
<TabList className='flex items-start w-fit clr-bg-pop'>
<TablistTools />
<RSTabsMenu />
<ConceptTab>Паспорт схемы</ConceptTab>
<ConceptTab className='border-x-2 clr-border min-w-[10rem] flex justify-between gap-2'>
<span>Конституенты</span>
@ -112,4 +112,4 @@ function RSFormTabs() {
</div>);
}
export default RSFormTabs;
export default RSTabs;

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
@ -11,8 +11,9 @@ import { useAuth } from '../../context/AuthContext';
import { useRSForm } from '../../context/RSFormContext';
import useDropdown from '../../hooks/useDropdown';
import { claimOwnershipProc, deleteRSFormProc, downloadRSFormProc, shareCurrentURLProc } from '../../utils/procedures';
import UploadRSFormModal from './UploadRSFormModal';
function TablistTools() {
function RSTabsMenu() {
const navigate = useNavigate();
const { user } = useAuth();
const {
@ -23,6 +24,7 @@ function TablistTools() {
} = useRSForm();
const schemaMenu = useDropdown();
const editMenu = useDropdown();
const [showUploadModal, setShowUploadModal] = useState(false);
const handleClaimOwner = useCallback(() => {
editMenu.hide();
@ -41,9 +43,8 @@ function TablistTools() {
}, [schemaMenu, download, schema?.alias]);
const handleUpload = useCallback(() => {
// TODO: implement
schemaMenu.hide();
toast.info('Замена содержимого на файл Экстеора');
setShowUploadModal(true);
}, [schemaMenu]);
const handleClone = useCallback(() => {
@ -58,6 +59,11 @@ function TablistTools() {
}, [schemaMenu]);
return (
<>
<UploadRSFormModal
show={showUploadModal}
hideWindow={() => { setShowUploadModal(false); }}
/>
<div className='flex items-center w-fit'>
<div ref={schemaMenu.ref}>
<Button
@ -142,7 +148,8 @@ function TablistTools() {
/>
</div>
</div>
</>
);
}
export default TablistTools
export default RSTabsMenu

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import Checkbox from '../../components/Common/Checkbox';
import FileInput from '../../components/Common/FileInput';
import Modal from '../../components/Common/Modal';
import { useRSForm } from '../../context/RSFormContext';
import { IRSFormUploadData } from '../../utils/models';
interface UploadRSFormModalProps {
show: boolean
hideWindow: () => void
}
function UploadRSFormModal({ show, hideWindow }: UploadRSFormModalProps) {
const { upload } = useRSForm();
const [loadMetadata, setLoadMetadata] = useState(false);
const [file, setFile] = useState<File | undefined>()
const handleSubmit = () => {
hideWindow();
if (!file) {
return;
}
const data: IRSFormUploadData = {
load_metadata: loadMetadata,
file: file,
fileName: file.name
};
upload(data, () => toast.success('Схема загружена из файла'));
};
const handleFile = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setFile(event.target.files[0]);
} else {
setFile(undefined)
}
}
return (
<Modal
title='Загрузка схемы из Экстеор'
show={show}
hideWindow={hideWindow}
canSubmit={!!file}
onSubmit={handleSubmit}
>
<div className='max-w-[20rem]'>
<FileInput label='Загрузить файл'
acceptType='.trs'
onChange={handleFile}
/>
<Checkbox label='Загружать метаданные'
value={loadMetadata}
onChange={event => { setLoadMetadata(event.target.checked); }}
/>
</div>
</Modal>
)
}
export default UploadRSFormModal;

View File

@ -1,13 +1,13 @@
import { useParams } from 'react-router-dom';
import { RSFormState } from '../../context/RSFormContext';
import RSFormTabs from './RSFormTabs';
import RSTabs from './RSTabs';
function RSFormPage() {
const { id } = useParams();
return (
<RSFormState schemaID={id ?? ''}>
<RSFormTabs />
<RSTabs />
</RSFormState>
);
}

View File

@ -8,12 +8,10 @@ import {
ExpressionParse,
IConstituentaList,
IConstituentaMeta,
ICstCreateData,
ICstCreatedResponse,
ICstMovetoData,
ICstUpdateData,
ICstCreateData, ICstCreatedResponse, ICstMovetoData, ICstUpdateData,
ICurrentUser, IRSFormCreateData, IRSFormData,
IRSFormMeta, IRSFormUpdateData, IUserInfo, IUserLoginData, IUserProfile, IUserSignupData, RSExpression
IRSFormMeta, IRSFormUpdateData, IRSFormUploadData, IUserInfo,
IUserLoginData, IUserProfile, IUserSignupData, RSExpression
} from './models'
// ================ Data transfer types ================
@ -203,9 +201,30 @@ export function postCheckExpression(schema: string, request: FrontExchange<RSExp
});
}
export function patchResetAliases(target: string, request: FrontPull<IRSFormData>) {
AxiosPatch({
title: `Reset alias for RSForm id=${target}`,
endpoint: `${config.url.BASE}rsforms/${target}/reset-aliases/`,
request: request
});
}
export function patchUploadTRS(target: string, request: FrontExchange<IRSFormUploadData, IRSFormData>) {
AxiosPatch({
title: `Replacing data with trs file for RSForm id=${target}`,
endpoint: `${config.url.BASE}rsforms/${target}/load-trs/`,
request: request,
options: {
headers: {
'Content-Type': 'multipart/form-data'
}
}
});
}
// ============ Helper functions =============
function AxiosGet<ResponseData>({ endpoint, request, title, options }: IAxiosRequest<undefined, ResponseData>) {
console.log(`[[${title}]] requested`);
console.log(`REQUEST: [[${title}]]`);
if (request.setLoading) request?.setLoading(true);
axios.get<ResponseData>(endpoint, options)
.then((response) => {
@ -222,7 +241,7 @@ function AxiosGet<ResponseData>({ endpoint, request, title, options }: IAxiosReq
function AxiosPost<RequestData, ResponseData>(
{ endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData>
) {
console.log(`[[${title}]] posted`);
console.log(`POST: [[${title}]]`);
if (request.setLoading) request.setLoading(true);
axios.post<ResponseData>(endpoint, request.data, options)
.then((response) => {
@ -239,7 +258,7 @@ function AxiosPost<RequestData, ResponseData>(
function AxiosDelete<RequestData, ResponseData>(
{ endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData>
) {
console.log(`[[${title}]] is being deleted`);
console.log(`DELETE: [[${title}]]`);
if (request.setLoading) request.setLoading(true);
axios.delete<ResponseData>(endpoint, options)
.then((response) => {
@ -256,7 +275,7 @@ function AxiosDelete<RequestData, ResponseData>(
function AxiosPatch<RequestData, ResponseData>(
{ endpoint, request, title, options }: IAxiosRequest<RequestData, ResponseData>
) {
console.log(`[[${title}]] is being patrially updated`);
console.log(`PATCH: [[${title}]]`);
if (request.setLoading) request.setLoading(true);
axios.patch<ResponseData>(endpoint, request.data, options)
.then((response) => {

View File

@ -179,6 +179,12 @@ extends IRSFormUpdateData {
fileName?: string
}
export interface IRSFormUploadData {
load_metadata: boolean
file: File
fileName: string
}
// ================ Misc types ================
// Constituenta edit mode
export enum EditMode {

View File

@ -21,7 +21,7 @@ export function claimOwnershipProc(
}
export function deleteRSFormProc(
destroy: (callback: DataCallback) => void,
destroy: (callback?: () => void) => void,
navigate: (path: string) => void
) {
if (!window.confirm('Вы уверены, что хотите удалить данную схему?')) {