Compare commits

..

4 Commits

Author SHA1 Message Date
Ivan
f390762c7d npm update
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2024-10-28 14:57:50 +03:00
Ivan
f0af0db3f9 F: Constituenta relocation pt2 2024-10-28 14:52:30 +03:00
Ivan
2e775463a9 R: Improve CSS setup 2024-10-23 23:22:35 +03:00
Ivan
731ca12308 R: Add eslint-plugin-react-hooks and fix issues 2024-10-23 16:27:56 +03:00
37 changed files with 1014 additions and 2571 deletions

View File

@ -59,6 +59,7 @@ This readme file is used mostly to document project dependencies and conventions
- postcss
- autoprefixer
- eslint-plugin-simple-import-sort
- eslint-plugin-react-hooks
- eslint-plugin-tsdoc
- vite
- jest

View File

@ -267,7 +267,51 @@ class OperationSchema:
self.save(update_fields=['time_update'])
return True
def after_create_cst(self, source: RSForm, cst_list: list[Constituenta]) -> None:
def relocate_down(self, source: RSForm, destination: RSForm, items: list[Constituenta]):
''' Move list of constituents to specific schema inheritor. '''
self.cache.ensure_loaded()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(destination.model.pk)
self._undo_substitutions_cst(items, operation, destination)
inheritance_to_delete = [item for item in self.cache.inheritance[operation.pk] if item.parent_id in items]
for item in inheritance_to_delete:
self.cache.remove_inheritance(item)
Inheritance.objects.filter(operation_id=operation.pk, parent__in=items).delete()
def relocate_up(self, source: RSForm, destination: RSForm, items: list[Constituenta]) -> list[Constituenta]:
''' Move list of constituents to specific schema upstream. '''
self.cache.ensure_loaded()
self.cache.insert_schema(source)
self.cache.insert_schema(destination)
operation = self.cache.get_operation(source.model.pk)
alias_mapping: dict[str, str] = {}
for item in self.cache.inheritance[operation.pk]:
if item.parent_id in destination.cache.by_id:
source_cst = source.cache.by_id[item.child_id]
destination_cst = destination.cache.by_id[item.parent_id]
alias_mapping[source_cst.alias] = destination_cst.alias
new_items = destination.insert_copy(items, initial_mapping=alias_mapping)
for index, cst in enumerate(new_items):
new_inheritance = Inheritance.objects.create(
operation=operation,
child=items[index],
parent=cst
)
self.cache.insert_inheritance(new_inheritance)
self.after_create_cst(destination, new_items, exclude=[operation.pk])
return new_items
def after_create_cst(
self, source: RSForm,
cst_list: list[Constituenta],
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
self.cache.insert_schema(source)
inserted_aliases = [cst.alias for cst in cst_list]
@ -281,7 +325,7 @@ class OperationSchema:
if cst is not None:
alias_mapping[alias] = cst
operation = self.cache.get_operation(source.model.pk)
self._cascade_inherit_cst(operation.pk, source, cst_list, alias_mapping)
self._cascade_inherit_cst(operation.pk, source, cst_list, alias_mapping, exclude)
def after_change_cst_type(self, source: RSForm, target: Constituenta) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
@ -344,18 +388,20 @@ class OperationSchema:
mapping={}
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def _cascade_inherit_cst(
self,
target_operation: int,
self, target_operation: int,
source: RSForm,
items: list[Constituenta],
mapping: CstMapping
mapping: CstMapping,
exclude: Optional[list[int]] = None
) -> None:
children = self.cache.graph.outputs[target_operation]
if len(children) == 0:
return
for child_id in children:
self._execute_inherit_cst(child_id, source, items, mapping)
if not exclude or child_id not in exclude:
self._execute_inherit_cst(child_id, source, items, mapping)
def _execute_inherit_cst(
self,
@ -827,6 +873,10 @@ class OssCache:
''' Remove substitution from cache. '''
self.substitutions[target.operation_id].remove(target)
def remove_inheritance(self, target: Inheritance) -> None:
''' Remove inheritance from cache. '''
self.inheritance[target.operation_id].remove(target)
def unfold_sub(self, sub: Substitution) -> tuple[RSForm, RSForm, Constituenta, Constituenta]:
operation = self.operation_by_id[sub.operation_id]
parents = self.graph.inputs[operation.pk]

View File

@ -1,4 +1,6 @@
''' Models: Change propagation facade - managing all changes in OSS. '''
from typing import Optional
from apps.library.models import LibraryItem, LibraryItemType
from apps.rsform.models import Constituenta, RSForm
@ -14,42 +16,53 @@ class PropagationFacade:
''' Change propagation API. '''
@staticmethod
def after_create_cst(source: RSForm, new_cst: list[Constituenta]) -> None:
def after_create_cst(source: RSForm, new_cst: list[Constituenta], exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when new constituent is created. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
OperationSchema(host).after_create_cst(source, new_cst)
if exclude is None or host.pk not in exclude:
OperationSchema(host).after_create_cst(source, new_cst)
@staticmethod
def after_change_cst_type(source: RSForm, target: Constituenta) -> None:
def after_change_cst_type(source: RSForm, target: Constituenta, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions when constituenta type is changed. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
OperationSchema(host).after_change_cst_type(source, target)
if exclude is None or host.pk not in exclude:
OperationSchema(host).after_change_cst_type(source, target)
@staticmethod
def after_update_cst(source: RSForm, target: Constituenta, data: dict, old_data: dict) -> None:
def after_update_cst(
source: RSForm,
target: Constituenta,
data: dict,
old_data: dict,
exclude: Optional[list[int]] = None
) -> None:
''' Trigger cascade resolutions when constituenta data is changed. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
OperationSchema(host).after_update_cst(source, target, data, old_data)
if exclude is None or host.pk not in exclude:
OperationSchema(host).after_update_cst(source, target, data, old_data)
@staticmethod
def before_delete_cst(source: RSForm, target: list[Constituenta]) -> None:
def before_delete_cst(source: RSForm, target: list[Constituenta], exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are deleted. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
OperationSchema(host).before_delete_cst(source, target)
if exclude is None or host.pk not in exclude:
OperationSchema(host).before_delete_cst(source, target)
@staticmethod
def before_substitute(source: RSForm, substitutions: CstSubstitution) -> None:
def before_substitute(source: RSForm, substitutions: CstSubstitution, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before constituents are substituted. '''
hosts = _get_oss_hosts(source.model)
for host in hosts:
OperationSchema(host).before_substitute(source, substitutions)
if exclude is None or host.pk not in exclude:
OperationSchema(host).before_substitute(source, substitutions)
@staticmethod
def before_delete_schema(item: LibraryItem) -> None:
def before_delete_schema(item: LibraryItem, exclude: Optional[list[int]] = None) -> None:
''' Trigger cascade resolutions before schema is deleted. '''
if item.item_type != LibraryItemType.RSFORM:
return
@ -58,4 +71,4 @@ class PropagationFacade:
return
schema = RSForm(item)
PropagationFacade.before_delete_cst(schema, list(schema.constituents().order_by('order')))
PropagationFacade.before_delete_cst(schema, list(schema.constituents().order_by('order')), exclude)

View File

@ -9,6 +9,7 @@ from .data_access import (
OperationSerializer,
OperationTargetSerializer,
OperationUpdateSerializer,
RelocateConstituentsSerializer,
SetOperationInputSerializer
)
from .responses import ConstituentaReferenceResponse, NewOperationResponse, NewSchemaResponse

View File

@ -11,7 +11,7 @@ from apps.rsform.models import Constituenta
from apps.rsform.serializers import SubstitutionSerializerBase
from shared import messages as msg
from ..models import Argument, Operation, OperationSchema, OperationType
from ..models import Argument, Inheritance, Operation, OperationSchema, OperationType
from .basics import OperationPositionSerializer, SubstitutionExSerializer
@ -118,8 +118,6 @@ class OperationUpdateSerializer(serializers.Serializer):
return attrs
class OperationTargetSerializer(serializers.Serializer):
''' Serializer: Target single operation. '''
target = PKField(many=False, queryset=Operation.objects.all().only('oss_id', 'result_id'))
@ -224,3 +222,62 @@ class OperationSchemaSerializer(serializers.ModelSerializer):
).order_by('pk'):
result['substitutions'].append(substitution)
return result
class RelocateConstituentsSerializer(serializers.Serializer):
''' Serializer: Relocate constituents. '''
destination = PKField(
many=False,
queryset=LibraryItem.objects.all().only('id')
)
items = PKField(
many=True,
allow_empty=False,
queryset=Constituenta.objects.all()
)
def validate(self, attrs):
attrs['destination'] = attrs['destination'].id
attrs['source'] = attrs['items'][0].schema_id
# TODO: check permissions for editing source and destination
if attrs['source'] == attrs['destination']:
raise serializers.ValidationError({
'destination': msg.sourceEqualDestination()
})
for cst in attrs['items']:
if cst.schema_id != attrs['source']:
raise serializers.ValidationError({
f'{cst.pk}': msg.constituentaNotInRSform(attrs['items'][0].schema.title)
})
if Inheritance.objects.filter(child__in=attrs['items']).exists():
raise serializers.ValidationError({
'items': msg.RelocatingInherited()
})
oss = LibraryItem.objects \
.filter(operations__result_id=attrs['destination']) \
.filter(operations__result_id=attrs['source']).only('id')
if not oss.exists():
raise serializers.ValidationError({
'destination': msg.schemasNotConnected()
})
attrs['oss'] = oss[0].pk
if Argument.objects.filter(
operation__result_id=attrs['destination'],
argument__result_id=attrs['source']
).exists():
attrs['move_down'] = True
elif Argument.objects.filter(
operation__result_id=attrs['source'],
argument__result_id=attrs['destination']
).exists():
attrs['move_down'] = False
else:
raise serializers.ValidationError({
'destination': msg.schemasNotConnected()
})
return attrs

View File

@ -339,3 +339,65 @@ class TestChangeOperations(EndpointTester):
self.ks5.refresh_from_db()
self.assertNotEqual(self.operation4.result, None)
self.assertEqual(self.ks5.constituents().count(), 8)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents_up(self):
ks1_old_count = self.ks1.constituents().count()
ks4_old_count = self.ks4.constituents().count()
operation6 = self.owned.create_operation(
alias='6',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(operation6.pk, [self.operation1, self.operation2])
self.owned.execute_operation(operation6)
operation6.refresh_from_db()
ks6 = RSForm(operation6.result)
ks6A1 = ks6.insert_new('A1')
ks6_old_count = ks6.constituents().count()
data = {
'destination': self.ks1.model.pk,
'items': [ks6A1.pk]
}
self.executeOK(data=data)
ks6.refresh_from_db()
self.ks1.refresh_from_db()
self.ks4.refresh_from_db()
self.assertEqual(ks6.constituents().count(), ks6_old_count)
self.assertEqual(self.ks1.constituents().count(), ks1_old_count + 1)
self.assertEqual(self.ks4.constituents().count(), ks4_old_count + 1)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents_down(self):
ks1_old_count = self.ks1.constituents().count()
ks4_old_count = self.ks4.constituents().count()
operation6 = self.owned.create_operation(
alias='6',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(operation6.pk, [self.operation1, self.operation2])
self.owned.execute_operation(operation6)
operation6.refresh_from_db()
ks6 = RSForm(operation6.result)
ks6_old_count = ks6.constituents().count()
data = {
'destination': ks6.model.pk,
'items': [self.ks1X2.pk]
}
self.executeOK(data=data)
ks6.refresh_from_db()
self.ks1.refresh_from_db()
self.ks4.refresh_from_db()
self.ks4D2.refresh_from_db()
self.ks5D4.refresh_from_db()
self.assertEqual(ks6.constituents().count(), ks6_old_count)
self.assertEqual(self.ks1.constituents().count(), ks1_old_count - 1)
self.assertEqual(self.ks4.constituents().count(), ks4_old_count - 1)
self.assertEqual(self.ks4D2.definition_formal, r'DEL X2 X3 S1 D1')
self.assertEqual(self.ks5D4.definition_formal, r'X1 X2 X3 S1 D1 D2 D3')

View File

@ -1,7 +1,7 @@
''' Testing API: Operation Schema. '''
from apps.library.models import AccessPolicy, Editor, LibraryItem, LibraryItemType
from apps.oss.models import Operation, OperationSchema, OperationType
from apps.rsform.models import RSForm
from apps.rsform.models import Constituenta, RSForm
from shared.EndpointTester import EndpointTester, decl_endpoint
@ -18,7 +18,6 @@ class TestOssViewset(EndpointTester):
self.private_id = self.private.model.pk
self.invalid_id = self.private.model.pk + 1337
def populateData(self):
self.ks1 = RSForm.create(
alias='KS1',
@ -135,7 +134,6 @@ class TestOssViewset(EndpointTester):
self.executeForbidden(data=data, item=self.unowned_id)
self.executeForbidden(data=data, item=self.private_id)
@decl_endpoint('/api/oss/{item}/create-operation', method='post')
def test_create_operation(self):
self.populateData()
@ -499,3 +497,87 @@ class TestOssViewset(EndpointTester):
self.assertEqual(len(items), 1)
self.assertEqual(items[0].alias, 'X1')
self.assertEqual(items[0].term_resolved, self.ks2X1.term_resolved)
@decl_endpoint('/api/oss/get-predecessor', method='post')
def test_get_predecessor(self):
self.populateData()
self.ks1X2 = self.ks1.insert_new('X2')
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
self.ks3X2 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.executeBadData(data={'target': self.invalid_id})
response = self.executeOK(data={'target': self.ks1X1.pk})
self.assertEqual(response.data['id'], self.ks1X1.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
response = self.executeOK(data={'target': self.ks3X2.pk})
self.assertEqual(response.data['id'], self.ks1X2.pk)
self.assertEqual(response.data['schema'], self.ks1.model.pk)
@decl_endpoint('/api/oss/relocate-constituents', method='post')
def test_relocate_constituents(self):
self.populateData()
self.ks1X2 = self.ks1.insert_new('X2', convention='test')
self.owned.execute_operation(self.operation3)
self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result)
self.ks3X2 = Constituenta.objects.get(as_child__parent_id=self.ks1X2.pk)
self.ks3X10 = self.ks3.insert_new('X10', convention='test2')
# invalid destination
data = {
'destination': self.invalid_id,
'items': []
}
self.executeBadData(data=data)
# empty items
data = {
'destination': self.ks1.model.pk,
'items': []
}
self.executeBadData(data=data)
# source == destination
data = {
'destination': self.ks1.model.pk,
'items': [self.ks1X1.pk]
}
self.executeBadData(data=data)
# moving inherited
data = {
'destination': self.ks1.model.pk,
'items': [self.ks3X2.pk]
}
self.executeBadData(data=data)
# source and destination are not connected
data = {
'destination': self.ks2.model.pk,
'items': [self.ks1X1.pk]
}
self.executeBadData(data=data)
data = {
'destination': self.ks3.model.pk,
'items': [self.ks1X2.pk]
}
self.ks3X2.refresh_from_db()
self.assertEqual(self.ks3X2.convention, 'test')
self.executeOK(data=data)
self.assertFalse(Constituenta.objects.filter(as_child__parent_id=self.ks1X2.pk).exists())
data = {
'destination': self.ks1.model.pk,
'items': [self.ks3X10.pk]
}
self.executeOK(data=data)
self.assertTrue(Constituenta.objects.filter(as_parent__child_id=self.ks3X10.pk).exists())
self.ks1X3 = Constituenta.objects.get(as_parent__child_id=self.ks3X10.pk)
self.assertEqual(self.ks1X3.convention, 'test2')

View File

@ -14,7 +14,7 @@ from rest_framework.response import Response
from apps.library.models import LibraryItem, LibraryItemType
from apps.library.serializers import LibraryItemSerializer
from apps.rsform.models import Constituenta
from apps.rsform.models import Constituenta, RSForm
from apps.rsform.serializers import CstTargetSerializer
from shared import messages as msg
from shared import permissions
@ -42,7 +42,8 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'create_input',
'set_input',
'update_operation',
'execute_operation'
'execute_operation',
'relocate_constituents'
]:
permission_list = [permissions.ItemEditor]
elif self.action in ['details']:
@ -385,3 +386,36 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
'schema': cst.schema_id
}
)
@extend_schema(
summary='relocate constituents from one schema to another',
tags=['OSS'],
request=s.RelocateConstituentsSerializer(),
responses={
c.HTTP_200_OK: s.OperationSchemaSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=False, methods=['post'], url_path='relocate-constituents')
def relocate_constituents(self, request: Request) -> Response:
''' Relocate constituents from one schema to another. '''
serializer = s.RelocateConstituentsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
oss = m.OperationSchema(LibraryItem.objects.get(pk=data['oss']))
source = RSForm(LibraryItem.objects.get(pk=data['source']))
destination = RSForm(LibraryItem.objects.get(pk=data['destination']))
with transaction.atomic():
if data['move_down']:
oss.relocate_down(source, destination, data['items'])
m.PropagationFacade.before_delete_cst(source, data['items'])
source.delete_cst(data['items'])
else:
new_items = oss.relocate_up(source, destination, data['items'])
m.PropagationFacade.after_create_cst(destination, new_items, exclude=[oss.model.pk])
return Response(status=c.HTTP_200_OK)

View File

@ -64,6 +64,7 @@ class RSForm:
def refresh_from_db(self) -> None:
''' Model wrapper. '''
self.model.refresh_from_db()
self.cache = RSFormCache(self)
def constituents(self) -> QuerySet[Constituenta]:
''' Get QuerySet containing all constituents of current RSForm. '''

View File

@ -38,6 +38,18 @@ def operationResultFromAnotherOSS():
return 'Схема является результатом другой ОСС'
def schemasNotConnected():
return 'Концептуальные схемы не связаны через ОСС'
def sourceEqualDestination():
return 'Схема-источник и схема-получатель не могут быть одинаковыми'
def RelocatingInherited():
return 'Невозможно переместить наследуемые конституенты'
def operationInputAlreadyConnected():
return 'Схема уже подключена к другой операции'

View File

@ -2,8 +2,7 @@ import globals from 'globals';
import typescriptPlugin from 'typescript-eslint';
import typescriptParser from '@typescript-eslint/parser';
import reactPlugin from 'eslint-plugin-react';
// import { fixupPluginRules } from '@eslint/compat';
// import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
@ -35,7 +34,7 @@ export default [
{
plugins: {
'react': reactPlugin,
// 'react-hooks': fixupPluginRules(reactHooksPlugin),
'react-hooks': reactHooksPlugin,
'simple-import-sort': simpleImportSort
},
settings: { react: { version: 'detect' } },
@ -56,7 +55,9 @@ export default [
'react-refresh/only-export-components': ['off', { allowConstantExport: true }],
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'error'
'simple-import-sort/exports': 'error',
...reactHooksPlugin.configs.recommended.rules
}
},
{

File diff suppressed because it is too large Load Diff

View File

@ -14,21 +14,22 @@
"dependencies": {
"@lezer/lr": "^1.4.2",
"@tanstack/react-table": "^8.20.5",
"@uiw/codemirror-themes": "^4.23.5",
"@uiw/react-codemirror": "^4.23.5",
"@uiw/codemirror-themes": "^4.23.6",
"@uiw/react-codemirror": "^4.23.6",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"framer-motion": "^11.5.6",
"eslint-plugin-react-hooks": "^5.0.0",
"framer-motion": "^11.11.10",
"html-to-image": "^1.11.11",
"js-file-download": "^0.4.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.1",
"react-error-boundary": "^4.1.2",
"react-icons": "^5.3.0",
"react-intl": "^6.8.0",
"react-intl": "^6.8.4",
"react-loader-spinner": "^6.1.6",
"react-router-dom": "^6.27.0",
"react-select": "^5.8.1",
"react-select": "^5.8.2",
"react-tabs": "^6.0.2",
"react-toastify": "^10.0.6",
"react-tooltip": "^5.28.0",
@ -39,16 +40,16 @@
},
"devDependencies": {
"@lezer/generator": "^1.7.1",
"@types/jest": "^29.5.13",
"@types/node": "^22.7.6",
"@types/react": "^18.3.11",
"@types/jest": "^29.5.14",
"@types/node": "^22.8.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.2",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.12.0",
"eslint-plugin-react": "^7.37.1",
"eslint": "^9.13.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.11.0",
"jest": "^29.7.0",
@ -56,8 +57,8 @@
"tailwindcss": "^3.4.14",
"ts-jest": "^29.2.5",
"typescript": "^5.6.3",
"typescript-eslint": "^8.9.0",
"vite": "^5.4.9"
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
},
"jest": {
"preset": "ts-jest",

View File

@ -3,6 +3,7 @@
*/
import {
ICstRelocateData,
IInputCreatedResponse,
IOperationCreateData,
IOperationCreatedResponse,
@ -76,6 +77,13 @@ export function postExecuteOperation(oss: string, request: FrontExchange<ITarget
});
}
export function postRelocateConstituents(request: FrontPush<ICstRelocateData>) {
AxiosPost({
endpoint: `/api/oss/relocate-constituents`,
request: request
});
}
export function postFindPredecessor(request: FrontExchange<ITargetCst, IConstituentaReference>) {
AxiosPost({
endpoint: `/api/oss/get-predecessor`,

View File

@ -61,7 +61,7 @@ function PickMultiConstituenta({
newGraph.foldNode(item.id);
});
return newGraph;
}, [schema.graph, data]);
}, [data, schema.graph, schema.items]);
useLayoutEffect(() => {
if (filtered.length === 0) {

View File

@ -63,7 +63,7 @@ function PickSchema({
);
}
setFiltered(newFiltered);
}, [filterText, filterLocation]);
}, [filterText, filterLocation, baseFiltered]);
const columns = useMemo(
() => [

View File

@ -146,7 +146,7 @@ function DataTable<TData extends RowData>({
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
}
return colSizes;
}, [tableImpl.getState().columnSizingInfo, tableImpl.getState().columnSizing]);
}, [tableImpl]);
return (
<div tabIndex={-1} id={id} className={className} style={{ minHeight: fixedSize, maxHeight: fixedSize, ...style }}>

View File

@ -18,7 +18,7 @@ function AnimateFade({ style, noFadeIn, noFadeOut, children, hideContent, ...res
animate={hideContent ? 'hidden' : 'active'}
variants={animateFade.variants}
exit={{ ...(!noFadeOut ? animateFade.exit : {}) }}
style={{ display: hideContent ? 'none' : '', ...style }}
style={{ display: hideContent ? 'none' : '', willChange: 'auto', ...style }}
{...restProps}
>
{children}

View File

@ -238,7 +238,7 @@ export const LibraryState = ({ children }: React.PropsWithChildren) => {
});
}
},
[reloadItems, user]
[reloadItems]
);
const destroyItem = useCallback(
@ -254,7 +254,7 @@ export const LibraryState = ({ children }: React.PropsWithChildren) => {
})
});
},
[reloadItems, user]
[reloadItems]
);
const cloneItem = useCallback(
@ -291,7 +291,7 @@ export const LibraryState = ({ children }: React.PropsWithChildren) => {
})
});
},
[reloadItems, user]
[reloadItems]
);
return (

View File

@ -17,12 +17,14 @@ import {
patchUpdateOperation,
patchUpdatePositions,
postCreateOperation,
postExecuteOperation
postExecuteOperation,
postRelocateConstituents
} from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError';
import { AccessPolicy, ILibraryItem } from '@/models/library';
import { ILibraryUpdateData } from '@/models/library';
import {
ICstRelocateData,
IOperationCreateData,
IOperationData,
IOperationDeleteData,
@ -65,6 +67,7 @@ interface IOssContext {
setInput: (data: IOperationSetInputData, callback?: () => void) => void;
updateOperation: (data: IOperationUpdateData, callback?: () => void) => void;
executeOperation: (data: ITargetOperation, callback?: () => void) => void;
relocateConstituents: (data: ICstRelocateData, callback?: () => void) => void;
}
const OssContext = createContext<IOssContext | null>(null);
@ -94,7 +97,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
useEffect(() => {
oss.setID(itemID);
}, [itemID, oss.setID]);
}, [itemID, oss]);
const update = useCallback(
(data: ILibraryUpdateData, callback?: DataCallback<ILibraryItem>) => {
@ -115,7 +118,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.localUpdateItem, oss.setData]
[itemID, model, library, oss]
);
const setOwner = useCallback(
@ -139,7 +142,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems]
[itemID, model, library]
);
const setAccessPolicy = useCallback(
@ -163,7 +166,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems]
[itemID, model, library]
);
const setLocation = useCallback(
@ -187,7 +190,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems]
[itemID, model, library]
);
const setEditors = useCallback(
@ -211,7 +214,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems]
[itemID, model, library]
);
const savePositions = useCallback(
@ -228,7 +231,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, library.localUpdateTimestamp]
[itemID, library]
);
const createOperation = useCallback(
@ -246,7 +249,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, library.localUpdateTimestamp, oss.setData]
[itemID, library, oss]
);
const deleteOperation = useCallback(
@ -265,7 +268,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, library.reloadItems, oss.setData]
[itemID, library, oss]
);
const createInput = useCallback(
@ -284,7 +287,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, library.reloadItems, oss.setData]
[itemID, library, oss]
);
const setInput = useCallback(
@ -306,7 +309,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems, oss.setData]
[itemID, model, library, oss]
);
const updateOperation = useCallback(
@ -328,7 +331,7 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems, oss.setData]
[itemID, model, library, oss]
);
const executeOperation = useCallback(
@ -350,7 +353,29 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
}
});
},
[itemID, model, library.reloadItems, oss.setData]
[itemID, model, library, oss]
);
const relocateConstituents = useCallback(
(data: ICstRelocateData, callback?: () => void) => {
if (!model) {
return;
}
setProcessingError(undefined);
postRelocateConstituents({
data: data,
showError: true,
setLoading: setProcessing,
onError: setProcessingError,
onSuccess: () => {
oss.reload();
library.reloadItems(() => {
if (callback) callback();
});
}
});
},
[model, library, oss]
);
return (
@ -376,7 +401,8 @@ export const OssState = ({ itemID, children }: React.PropsWithChildren<OssStateP
createInput,
setInput,
updateOperation,
executeOperation
executeOperation,
relocateConstituents
}}
>
{children}

View File

@ -151,7 +151,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, schema, library.localUpdateItem, oss.invalidateItem]
[itemID, setSchema, schema, library, oss]
);
const upload = useCallback(
@ -172,7 +172,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, schema, library.localUpdateItem]
[itemID, setSchema, schema, library]
);
const setOwner = useCallback(
@ -195,7 +195,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, schema, library.localUpdateItem]
[itemID, schema, library]
);
const setAccessPolicy = useCallback(
@ -218,7 +218,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, schema, library.localUpdateItem]
[itemID, schema, library]
);
const setLocation = useCallback(
@ -240,7 +240,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, schema, library.reloadItems]
[itemID, schema, library]
);
const setEditors = useCallback(
@ -283,7 +283,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, schema, user, setSchema, library.localUpdateTimestamp, oss.invalidateItem]
[itemID, schema, user, setSchema, library, oss]
);
const restoreOrder = useCallback(
@ -303,7 +303,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, schema, user, setSchema, library.localUpdateTimestamp]
[itemID, schema, user, setSchema, library]
);
const produceStructure = useCallback(
@ -322,7 +322,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[setSchema, itemID, library.localUpdateTimestamp, oss.invalidateItem]
[setSchema, itemID, library, oss]
);
const download = useCallback(
@ -354,7 +354,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, library.localUpdateTimestamp, oss.invalidateItem]
[itemID, setSchema, library, oss]
);
const cstDelete = useCallback(
@ -373,7 +373,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, library.localUpdateTimestamp, oss.invalidateItem]
[itemID, setSchema, library, oss]
);
const cstUpdate = useCallback(
@ -392,7 +392,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
})
});
},
[itemID, reload, library.localUpdateTimestamp, oss.invalidateItem]
[itemID, reload, library, oss]
);
const cstRename = useCallback(
@ -411,7 +411,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[setSchema, itemID, library.localUpdateTimestamp, oss.invalidateItem]
[setSchema, itemID, library, oss]
);
const cstSubstitute = useCallback(
@ -430,7 +430,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[setSchema, itemID, library.localUpdateTimestamp, oss.invalidateItem]
[setSchema, itemID, library, oss]
);
const cstMoveTo = useCallback(
@ -448,7 +448,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, library.localUpdateTimestamp]
[itemID, setSchema, library]
);
const versionCreate = useCallback(
@ -466,7 +466,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, library.localUpdateTimestamp]
[itemID, setSchema, library]
);
const findPredecessor = useCallback((data: ITargetCst, callback: (reference: IConstituentaReference) => void) => {
@ -537,7 +537,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[setSchema, library.localUpdateItem]
[setSchema, library]
);
const inlineSynthesis = useCallback(
@ -556,7 +556,7 @@ export const RSFormState = ({ itemID, versionID, children }: React.PropsWithChil
}
});
},
[itemID, setSchema, library.localUpdateTimestamp, oss.invalidateItem]
[setSchema, library, oss]
);
return (

View File

@ -44,7 +44,7 @@ function FormCreateCst({ schema, state, partialUpdate, setValidated }: FormCreat
const handleTypeChange = useCallback(
(target: CstType) => partialUpdate({ cst_type: target, alias: generateAlias(target, schema) }),
[partialUpdate, schema, generateAlias]
[partialUpdate, schema]
);
return (

View File

@ -62,6 +62,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
const cache = useRSFormCache();
const schemas = useMemo(
() => schemasIDs.map(id => cache.getSchema(id)).filter(item => item !== undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps
[schemasIDs, cache.getSchema]
);
@ -90,6 +91,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
useLayoutEffect(() => {
cache.preload(schemasIDs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schemasIDs]);
useLayoutEffect(() => {
@ -109,6 +111,7 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
return true;
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schemasIDs, schemas, cache.loading]);
useLayoutEffect(() => {

View File

@ -61,7 +61,7 @@ function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInli
<TabSchema selected={donorID} setSelected={setDonorID} receiver={receiver} />
</TabPanel>
),
[donorID]
[donorID, receiver]
);
const itemsPanel = useMemo(
() => (

View File

@ -30,7 +30,7 @@ function DlgRelocateConstituents({ oss, hideWindow, target, onSubmit }: DlgReloc
...node.outputs.map(id => oss.operationByID.get(id)!.result).filter(id => id !== null)
];
return ids.map(id => library.items.find(item => item.id === id)).filter(item => item !== undefined);
}, [oss, library.items]);
}, [oss, library.items, target.id]);
const [destination, setDestination] = useState<ILibraryItem | undefined>(undefined);
const [selected, setSelected] = useState<ConstituentaID[]>([]);
@ -42,7 +42,7 @@ function DlgRelocateConstituents({ oss, hideWindow, target, onSubmit }: DlgReloc
}
const destinationOperation = oss.items.find(item => item.result === destination.id);
return getRelocateCandidates(target.id, destinationOperation!.id, source.schema, oss);
}, [destination, source.schema?.items]);
}, [destination, target.id, source.schema, oss]);
const isValid = useMemo(() => !!destination && selected.length > 0, [destination, selected]);
@ -55,12 +55,15 @@ function DlgRelocateConstituents({ oss, hideWindow, target, onSubmit }: DlgReloc
}, []);
const handleSubmit = useCallback(() => {
if (!destination) {
return;
}
const data: ICstRelocateData = {
destination: target.result ?? 0,
items: []
destination: destination.id,
items: selected
};
onSubmit(data);
}, [target, onSubmit]);
}, [destination, onSubmit, selected]);
return (
<Modal

View File

@ -15,14 +15,17 @@ function useOssDetails({ target, items }: { target?: string; items: ILibraryItem
const [loading, setLoading] = useState(target != undefined);
const [error, setError] = useState<ErrorData>(undefined);
function setSchema(data?: IOperationSchemaData) {
if (!data) {
setInner(undefined);
return;
}
const newSchema = new OssLoader(data, items).produceOSS();
setInner(newSchema);
}
const setSchema = useCallback(
(data?: IOperationSchemaData) => {
if (!data) {
setInner(undefined);
return;
}
const newSchema = new OssLoader(data, items).produceOSS();
setInner(newSchema);
},
[items]
);
const reload = useCallback(
(setCustomLoading?: typeof setLoading, callback?: () => void) => {
@ -43,7 +46,7 @@ function useOssDetails({ target, items }: { target?: string; items: ILibraryItem
}
});
},
[target]
[target, setSchema]
);
useEffect(() => {

View File

@ -76,6 +76,7 @@ function useRSFormCache() {
}
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending]);
return { preload, getSchema, getConstituenta, getSchemaByCst, loading, error, setError };

View File

@ -1,19 +1,20 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { PARAMETER } from '@/utils/constants';
function useWindowSize() {
const isClient = typeof window === 'object';
function getSize() {
return {
const getSize = useCallback(
() => ({
width: isClient ? window.innerWidth : undefined,
height: isClient ? window.innerHeight : undefined,
isSmall: isClient && window.innerWidth < PARAMETER.smallScreen
};
}
}),
[isClient]
);
const [windowSize, setWindowSize] = useState(getSize);
@ -26,7 +27,7 @@ function useWindowSize() {
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
}, [isClient, getSize]);
return windowSize;
}

View File

@ -90,7 +90,7 @@ function LibraryPage() {
const toggleVisible = useCallback(() => setIsVisible(prev => toggleTristateFlag(prev)), [setIsVisible]);
const toggleOwned = useCallback(() => setIsOwned(prev => toggleTristateFlag(prev)), [setIsOwned]);
const toggleEditor = useCallback(() => setIsEditor(prev => toggleTristateFlag(prev)), [setIsEditor]);
const toggleFolderMode = useCallback(() => options.setFolderMode(prev => !prev), [options.setFolderMode]);
const toggleFolderMode = useCallback(() => options.setFolderMode(prev => !prev), [options]);
const toggleSubfolders = useCallback(() => setSubfolders(prev => !prev), [setSubfolders]);
const resetFilter = useCallback(() => {
@ -102,7 +102,7 @@ function LibraryPage() {
setIsEditor(undefined);
setFilterUser(undefined);
options.setLocation('');
}, [setHead, setIsVisible, setIsOwned, setIsEditor, setFilterUser, options.setLocation]);
}, [setHead, setIsVisible, setIsOwned, setIsEditor, setFilterUser, options]);
const promptRenameLocation = useCallback(() => {
setShowRenameLocation(true);
@ -119,7 +119,7 @@ function LibraryPage() {
toast.success(information.locationRenamed);
});
},
[location, library]
[options, library]
);
const handleDownloadCSV = useCallback(() => {
@ -159,7 +159,15 @@ function LibraryPage() {
onRenameLocation={promptRenameLocation}
/>
),
[options.location, library.folders, options.setLocation, toggleFolderMode, subfolders]
[
options.location,
library.folders,
options.setLocation,
toggleFolderMode,
promptRenameLocation,
toggleSubfolders,
subfolders
]
);
return (

View File

@ -19,6 +19,13 @@ function ManualsPage() {
const { mainHeight } = useConceptOptions();
const onSelectTopic = useCallback(
(newTopic: HelpTopic) => {
router.push(urls.help_topic(newTopic));
},
[router]
);
if (!Object.values(HelpTopic).includes(activeTopic)) {
setTimeout(() => {
router.push(urls.page404);
@ -27,13 +34,6 @@ function ManualsPage() {
return null;
}
const onSelectTopic = useCallback(
(newTopic: HelpTopic) => {
router.push(urls.help_topic(newTopic));
},
[router]
);
return (
<div className='flex mx-auto max-w-[80rem]' role='manuals' style={{ minHeight: mainHeight }}>
<TopicsList activeTopic={activeTopic} onChangeTopic={topic => onSelectTopic(topic)} />

View File

@ -145,13 +145,6 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
[controller, getPositions, flow]
);
const handleDeleteSelected = useCallback(() => {
if (controller.selected.length !== 1) {
return;
}
handleDeleteOperation(controller.selected[0]);
}, [controller, getPositions]);
const handleDeleteOperation = useCallback(
(target: OperationID) => {
if (!controller.canDelete(target)) {
@ -162,6 +155,13 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
[controller, getPositions]
);
const handleDeleteSelected = useCallback(() => {
if (controller.selected.length !== 1) {
return;
}
handleDeleteOperation(controller.selected[0]);
}, [controller, handleDeleteOperation]);
const handleCreateInput = useCallback(
(target: OperationID) => {
controller.createInput(target, getPositions());
@ -199,9 +199,9 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
const handleRelocateConstituents = useCallback(
(target: OperationID) => {
controller.promptRelocateConstituents(target);
controller.promptRelocateConstituents(target, getPositions());
},
[controller]
[controller, getPositions]
);
const handleFitView = useCallback(() => {
@ -282,7 +282,7 @@ function OssFlow({ isModified, setIsModified }: OssFlowProps) {
handleEditOperation(Number(node.id));
}
},
[handleEditOperation, controller.openOperationSchema]
[handleEditOperation, controller]
);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {

View File

@ -76,7 +76,7 @@ export interface IOssEditContext extends ILibraryItemEditor {
promptEditInput: (target: OperationID, positions: IOperationPosition[]) => void;
promptEditOperation: (target: OperationID, positions: IOperationPosition[]) => void;
executeOperation: (target: OperationID, positions: IOperationPosition[]) => void;
promptRelocateConstituents: (target: OperationID) => void;
promptRelocateConstituents: (target: OperationID, positions: IOperationPosition[]) => void;
}
const OssEditContext = createContext<IOssEditContext | null>(null);
@ -288,14 +288,11 @@ export const OssEditState = ({ selected, setSelected, children }: React.PropsWit
[model]
);
const promptDeleteOperation = useCallback(
(target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowDeleteOperation(true);
},
[model]
);
const promptDeleteOperation = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowDeleteOperation(true);
}, []);
const deleteOperation = useCallback(
(keepConstituents: boolean, deleteSchema: boolean) => {
@ -363,21 +360,19 @@ export const OssEditState = ({ selected, setSelected, children }: React.PropsWit
[model]
);
const promptRelocateConstituents = useCallback(
(target: OperationID) => {
setTargetOperationID(target);
setShowRelocateConstituents(true);
},
[model]
);
const promptRelocateConstituents = useCallback((target: OperationID, positions: IOperationPosition[]) => {
setPositions(positions);
setTargetOperationID(target);
setShowRelocateConstituents(true);
}, []);
const handleRelocateConstituents = useCallback(
(data: ICstRelocateData) => {
// TODO: implement backed call
console.log(data);
toast.success('В разработке');
model.savePositions({ positions: positions }, () =>
model.relocateConstituents(data, () => toast.success(information.changesSaved))
);
},
[model]
[model, positions]
);
return (

View File

@ -169,7 +169,11 @@ function FormConstituenta({
) : null}
{state ? (
<AnimatePresence>
<AnimateFade key='cst_expression_fade' hideContent={!state.definition_formal && isElementary}>
<AnimateFade
key='cst_expression_fade'
hideContent={!state.definition_formal && isElementary}
style={{ willChange: 'auto' }}
>
<EditorRSExpression
id='cst_expression'
label={

View File

@ -95,23 +95,20 @@ function EditorRSExpression({
});
}
const onShowError = useCallback(
(error: IRSErrorDescription, prefixLen: number) => {
if (!rsInput.current) {
return;
const onShowError = useCallback((error: IRSErrorDescription, prefixLen: number) => {
if (!rsInput.current) {
return;
}
let errorPosition = error.position - prefixLen;
if (errorPosition < 0) errorPosition = 0;
rsInput.current?.view?.dispatch({
selection: {
anchor: errorPosition,
head: errorPosition
}
let errorPosition = error.position - prefixLen;
if (errorPosition < 0) errorPosition = 0;
rsInput.current?.view?.dispatch({
selection: {
anchor: errorPosition,
head: errorPosition
}
});
rsInput.current?.view?.focus();
},
[activeCst]
);
});
rsInput.current?.view?.focus();
}, []);
const handleEdit = useCallback((id: TokenID, key?: string) => {
if (!rsInput.current?.editor || !rsInput.current.state || !rsInput.current.view) {

View File

@ -38,7 +38,7 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
const { accessLevel } = useAccessMode();
const intl = useIntl();
const router = useConceptNavigation();
const options = useConceptOptions();
const { setLocation, setFolderMode } = useConceptOptions();
const ownerSelector = useDropdown();
const onSelectUser = useCallback(
@ -60,11 +60,11 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
if (!item) {
return;
}
options.setLocation(item.location);
options.setFolderMode(true);
setLocation(item.location);
setFolderMode(true);
router.push(urls.library, event.ctrlKey || event.metaKey);
},
[options.setLocation, options.setFolderMode, item, router]
[setLocation, setFolderMode, item, router]
);
if (!item) {

View File

@ -341,7 +341,7 @@ export const RSEditState = ({
toast.success(information.newVersion(data.version));
});
},
[model, viewVersion]
[model]
);
const handleDeleteVersion = useCallback(

View File

@ -8,6 +8,16 @@
@tailwind components;
@tailwind utilities;
*,
*::after,
*::before {
box-sizing: border-box;
/* Uncomment to debug layering and overflow */
/* background: hsla(135, 50%, 50%, 0.05); */
/* outline: 2px solid hotpink; */
}
html {
-webkit-text-size-adjust: none;
-moz-text-size-adjust: none;
@ -42,6 +52,8 @@ body {
}
:root {
interpolate-size: allow-keywords;
font-size: var(--font-size-base);
line-height: var(--line-height);
font-family: var(--font-main);