Compare commits

...

27 Commits

Author SHA1 Message Date
Ivan
e8509e44b1 M: Improve fullname label and fix clone defaults
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled
2025-03-25 23:00:50 +03:00
Ivan
417583efa5 R: Change fieldname comment to description 2025-03-25 22:30:15 +03:00
Ivan
03fc03a108 F: Replace clickOutside with onBlur trigger for dropdowns 2025-03-20 19:46:50 +03:00
Ivan
419b5e6379 B: Fix pagination change rendering error 2025-03-20 19:30:58 +03:00
Ivan
1cd780504d F: Improve styling and accessibility 2025-03-20 18:24:07 +03:00
Ivan
09b56f49d8 T: Add telegram reporting 2025-03-20 16:17:06 +03:00
Ivan
39783ec74a B: Fix linter error 2025-03-20 15:36:54 +03:00
Ivan
7adbaed116 F: Enable compression for backend responses 2025-03-20 15:34:39 +03:00
Ivan
341db80e68 R: Improve ValueIcon component 2025-03-20 14:41:48 +03:00
Ivan
1365d27af8 Update index.html 2025-03-20 14:13:08 +03:00
Ivan
cda3b70227 Update production.conf 2025-03-20 14:05:35 +03:00
Ivan
ebc6740e35 F: Enable caching for generated assets 2025-03-20 13:57:15 +03:00
Ivan
9bfdc56789 F: Improve buttons accessibility 2025-03-20 11:50:00 +03:00
Ivan
f92e086b13 F: Accessibility improvements 2025-03-19 23:28:32 +03:00
Ivan
9f64282385 B: Fix font loading and small issues 2025-03-19 16:12:18 +03:00
Ivan
f21295061d F: Add StyleLint tooling and fix some CSS issues 2025-03-19 15:11:43 +03:00
Ivan
76902b34ae F: Improve OSS create operations 2025-03-19 13:25:20 +03:00
Ivan
ff744b5367 M: Revert to default selection behavior and fix minor issues 2025-03-19 13:03:26 +03:00
Ivan
6c13a9e774 F: Improve admin panel for User 2025-03-19 12:07:45 +03:00
Ivan
cb6664a606 npm update 2025-03-18 22:47:17 +03:00
Ivan
ec40fe04ac B: Fix search dropdown 2025-03-18 22:27:43 +03:00
Ivan
c341360e90 M: Minor UI improvements 2025-03-14 21:06:31 +03:00
Ivan
eb48014e2f R: Replace enums with const objects 2025-03-14 20:43:30 +03:00
Ivan
0781dad1cb R: Fix tests 2025-03-13 23:56:10 +03:00
Ivan
8bf829513f R: Remove unused symbols 2025-03-13 23:20:52 +03:00
Ivan
5f524e2e6b B: Fix redirect on first load 2025-03-13 20:20:59 +03:00
Ivan
ea8c86119c B: Fix dialog heights for fullscreen dialogs 2025-03-13 20:13:57 +03:00
190 changed files with 2873 additions and 1797 deletions

View File

@ -40,3 +40,16 @@ jobs:
run: | run: |
python manage.py check python manage.py check
python manage.py test python manage.py test
notify-failure:
runs-on: ubuntu-latest
needs: build
if: failure()
defaults:
run:
working-directory: .
steps:
- name: Send Telegram Notification
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ Backend build failed! Repository: ${{ github.repository }} Commit: ${{ github.sha }}"

View File

@ -49,3 +49,16 @@ jobs:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
notify-failure:
runs-on: ubuntu-latest
needs: build
if: failure()
defaults:
run:
working-directory: .
steps:
- name: Send Telegram Notification
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ Front build failed! Repository: ${{ github.repository }} Commit: ${{ github.sha }}"

View File

@ -21,6 +21,7 @@
"changeProcessCWD": true "changeProcessCWD": true
} }
], ],
"stylelint.enable": true,
"autopep8.args": [ "autopep8.args": [
"--max-line-length", "--max-line-length",
"120", "120",
@ -151,6 +152,7 @@
"rsconcept", "rsconcept",
"rsedit", "rsedit",
"rseditor", "rseditor",
"rsexpression",
"rsform", "rsform",
"rsforms", "rsforms",
"rsgraph", "rsgraph",

View File

@ -71,6 +71,10 @@ This readme file is used mostly to document project dependencies and conventions
- vite - vite
- jest - jest
- ts-jest - ts-jest
- stylelint
- stylelint-config-recommended
- stylelint-config-standard
- stylelint-config-tailwindcss
- @vitejs/plugin-react - @vitejs/plugin-react
- @types/jest - @types/jest
- @lezer/generator - @lezer/generator

View File

@ -75,4 +75,10 @@ server {
proxy_pass http://innerreact; proxy_pass http://innerreact;
proxy_redirect default; proxy_redirect default;
} }
location /assets/ {
proxy_pass http://innerreact/assets/;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
} }

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-25 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0005_alter_libraryitem_owner'),
]
operations = [
migrations.AlterField(
model_name='libraryitem',
name='comment',
field=models.TextField(blank=True, verbose_name='Описание'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-25 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0006_alter_libraryitem_comment'),
]
operations = [
migrations.RenameField(
model_name='libraryitem',
old_name='comment',
new_name='description',
),
]

View File

@ -69,8 +69,8 @@ class LibraryItem(Model):
max_length=255, max_length=255,
blank=True blank=True
) )
comment = TextField( description = TextField(
verbose_name='Комментарий', verbose_name='Описание',
blank=True blank=True
) )
visible = BooleanField( visible = BooleanField(

View File

@ -46,7 +46,7 @@ class TestLibraryItem(TestCase):
self.assertIsNone(item.owner) self.assertIsNone(item.owner)
self.assertEqual(item.title, 'Test') self.assertEqual(item.title, 'Test')
self.assertEqual(item.alias, '') self.assertEqual(item.alias, '')
self.assertEqual(item.comment, '') self.assertEqual(item.description, '')
self.assertEqual(item.visible, True) self.assertEqual(item.visible, True)
self.assertEqual(item.read_only, False) self.assertEqual(item.read_only, False)
self.assertEqual(item.access_policy, AccessPolicy.PUBLIC) self.assertEqual(item.access_policy, AccessPolicy.PUBLIC)
@ -59,13 +59,13 @@ class TestLibraryItem(TestCase):
title='Test', title='Test',
owner=self.user1, owner=self.user1,
alias='KS1', alias='KS1',
comment='Test comment', description='Test description',
location=LocationHead.COMMON location=LocationHead.COMMON
) )
self.assertEqual(item.owner, self.user1) self.assertEqual(item.owner, self.user1)
self.assertEqual(item.title, 'Test') self.assertEqual(item.title, 'Test')
self.assertEqual(item.alias, 'KS1') self.assertEqual(item.alias, 'KS1')
self.assertEqual(item.comment, 'Test comment') self.assertEqual(item.description, 'Test description')
self.assertEqual(item.location, LocationHead.COMMON) self.assertEqual(item.location, LocationHead.COMMON)

View File

@ -55,13 +55,13 @@ class LibraryViewSet(viewsets.ModelViewSet):
if operation.title != instance.title: if operation.title != instance.title:
operation.title = instance.title operation.title = instance.title
changed = True changed = True
if operation.comment != instance.comment: if operation.description != instance.description:
operation.comment = instance.comment operation.description = instance.description
changed = True changed = True
if changed: if changed:
update_list.append(operation) update_list.append(operation)
if update_list: if update_list:
Operation.objects.bulk_update(update_list, ['alias', 'title', 'comment']) Operation.objects.bulk_update(update_list, ['alias', 'title', 'description'])
def perform_destroy(self, instance: m.LibraryItem) -> None: def perform_destroy(self, instance: m.LibraryItem) -> None:
if instance.item_type == m.LibraryItemType.RSFORM: if instance.item_type == m.LibraryItemType.RSFORM:
@ -160,7 +160,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
clone.owner = cast(User, self.request.user) clone.owner = cast(User, self.request.user)
clone.title = serializer.validated_data['title'] clone.title = serializer.validated_data['title']
clone.alias = serializer.validated_data.get('alias', '') clone.alias = serializer.validated_data.get('alias', '')
clone.comment = serializer.validated_data.get('comment', '') clone.description = serializer.validated_data.get('description', '')
clone.visible = serializer.validated_data.get('visible', True) clone.visible = serializer.validated_data.get('visible', True)
clone.read_only = False clone.read_only = False
clone.access_policy = serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC) clone.access_policy = serializer.validated_data.get('access_policy', m.AccessPolicy.PUBLIC)
@ -168,7 +168,7 @@ class LibraryViewSet(viewsets.ModelViewSet):
with transaction.atomic(): with transaction.atomic():
clone.save() clone.save()
need_filter = 'items' in request.data need_filter = 'items' in request.data and len(request.data['items']) > 0
for cst in RSForm(item).constituents(): for cst in RSForm(item).constituents():
if not need_filter or cst.pk in request.data['items']: if not need_filter or cst.pk in request.data['items']:
cst.pk = None cst.pk = None

View File

@ -7,7 +7,16 @@ from . import models
class OperationAdmin(admin.ModelAdmin): class OperationAdmin(admin.ModelAdmin):
''' Admin model: Operation. ''' ''' Admin model: Operation. '''
ordering = ['oss'] ordering = ['oss']
list_display = ['id', 'oss', 'operation_type', 'result', 'alias', 'title', 'comment', 'position_x', 'position_y'] list_display = [
'id',
'oss',
'operation_type',
'result',
'alias',
'title',
'description',
'position_x',
'position_y']
search_fields = ['id', 'operation_type', 'title', 'alias'] search_fields = ['id', 'operation_type', 'title', 'alias']

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-25 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oss', '0008_alter_operation_result'),
]
operations = [
migrations.AlterField(
model_name='operation',
name='comment',
field=models.TextField(blank=True, verbose_name='Описание'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-25 19:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('oss', '0009_alter_operation_comment'),
]
operations = [
migrations.RenameField(
model_name='operation',
old_name='comment',
new_name='description',
),
]

View File

@ -53,8 +53,8 @@ class Operation(Model):
verbose_name='Название', verbose_name='Название',
blank=True blank=True
) )
comment = TextField( description = TextField(
verbose_name='Комментарий', verbose_name='Описание',
blank=True blank=True
) )

View File

@ -141,8 +141,8 @@ class OperationSchema:
if schema is not None: if schema is not None:
operation.alias = schema.alias operation.alias = schema.alias
operation.title = schema.title operation.title = schema.title
operation.comment = schema.comment operation.description = schema.description
operation.save(update_fields=['result', 'alias', 'title', 'comment']) operation.save(update_fields=['result', 'alias', 'title', 'description'])
if schema is not None and has_children: if schema is not None and has_children:
rsform = RSForm(schema) rsform = RSForm(schema)
@ -227,7 +227,7 @@ class OperationSchema:
owner=self.model.owner, owner=self.model.owner,
alias=operation.alias, alias=operation.alias,
title=operation.title, title=operation.title,
comment=operation.comment, description=operation.description,
visible=False, visible=False,
access_policy=self.model.access_policy, access_policy=self.model.access_policy,
location=self.model.location location=self.model.location

View File

@ -44,7 +44,7 @@ class OperationCreateSerializer(serializers.Serializer):
model = Operation model = Operation
fields = \ fields = \
'alias', 'operation_type', 'title', \ 'alias', 'operation_type', 'title', \
'comment', 'result', 'position_x', 'position_y' 'description', 'result', 'position_x', 'position_y'
create_schema = serializers.BooleanField(default=False, required=False) create_schema = serializers.BooleanField(default=False, required=False)
item_data = OperationCreateData() item_data = OperationCreateData()
@ -63,7 +63,7 @@ class OperationUpdateSerializer(serializers.Serializer):
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = Operation model = Operation
fields = 'alias', 'title', 'comment' fields = 'alias', 'title', 'description'
target = PKField(many=False, queryset=Operation.objects.all()) target = PKField(many=False, queryset=Operation.objects.all())
item_data = OperationUpdateData() item_data = OperationUpdateData()

View File

@ -28,6 +28,6 @@ class TestOperation(TestCase):
self.assertEqual(self.operation.result, None) self.assertEqual(self.operation.result, None)
self.assertEqual(self.operation.alias, 'KS1') self.assertEqual(self.operation.alias, 'KS1')
self.assertEqual(self.operation.title, '') self.assertEqual(self.operation.title, '')
self.assertEqual(self.operation.comment, '') self.assertEqual(self.operation.description, '')
self.assertEqual(self.operation.position_x, 0) self.assertEqual(self.operation.position_x, 0)
self.assertEqual(self.operation.position_y, 0) self.assertEqual(self.operation.position_y, 0)

View File

@ -123,7 +123,7 @@ class TestChangeAttributes(EndpointTester):
@decl_endpoint('/api/library/{item}', method='patch') @decl_endpoint('/api/library/{item}', method='patch')
def test_sync_from_result(self): def test_sync_from_result(self):
data = {'alias': 'KS111', 'title': 'New Title', 'comment': 'New Comment'} data = {'alias': 'KS111', 'title': 'New Title', 'description': 'New description'}
self.executeOK(data=data, item=self.ks1.model.pk) self.executeOK(data=data, item=self.ks1.model.pk)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
@ -131,7 +131,7 @@ class TestChangeAttributes(EndpointTester):
self.assertEqual(self.operation1.result, self.ks1.model) self.assertEqual(self.operation1.result, self.ks1.model)
self.assertEqual(self.operation1.alias, data['alias']) self.assertEqual(self.operation1.alias, data['alias'])
self.assertEqual(self.operation1.title, data['title']) self.assertEqual(self.operation1.title, data['title'])
self.assertEqual(self.operation1.comment, data['comment']) self.assertEqual(self.operation1.description, data['description'])
@decl_endpoint('/api/oss/{item}/update-operation', method='patch') @decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_sync_from_operation(self): def test_sync_from_operation(self):
@ -140,7 +140,7 @@ class TestChangeAttributes(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test3 mod', 'alias': 'Test3 mod',
'title': 'Test title mod', 'title': 'Test title mod',
'comment': 'Comment mod' 'description': 'Comment mod'
}, },
'positions': [], 'positions': [],
} }
@ -149,7 +149,7 @@ class TestChangeAttributes(EndpointTester):
self.ks3.refresh_from_db() self.ks3.refresh_from_db()
self.assertEqual(self.ks3.model.alias, data['item_data']['alias']) self.assertEqual(self.ks3.model.alias, data['item_data']['alias'])
self.assertEqual(self.ks3.model.title, data['item_data']['title']) self.assertEqual(self.ks3.model.title, data['item_data']['title'])
self.assertEqual(self.ks3.model.comment, data['item_data']['comment']) self.assertEqual(self.ks3.model.description, data['item_data']['description'])
@decl_endpoint('/api/library/{item}', method='delete') @decl_endpoint('/api/library/{item}', method='delete')
def test_destroy_oss_consequence(self): def test_destroy_oss_consequence(self):

View File

@ -281,7 +281,7 @@ class TestChangeOperations(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test4 mod', 'alias': 'Test4 mod',
'title': 'Test title mod', 'title': 'Test title mod',
'comment': 'Comment mod' 'description': 'Comment mod'
}, },
'positions': [], 'positions': [],
'substitutions': [ 'substitutions': [
@ -315,7 +315,7 @@ class TestChangeOperations(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test4 mod', 'alias': 'Test4 mod',
'title': 'Test title mod', 'title': 'Test title mod',
'comment': 'Comment mod' 'description': 'Comment mod'
}, },
'positions': [], 'positions': [],
'arguments': [self.operation1.pk], 'arguments': [self.operation1.pk],

View File

@ -143,7 +143,7 @@ class TestOssViewset(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test3', 'alias': 'Test3',
'title': 'Test title', 'title': 'Test title',
'comment': 'Тест кириллицы', 'description': 'Тест кириллицы',
'position_x': 1, 'position_x': 1,
'position_y': 1, 'position_y': 1,
}, },
@ -165,7 +165,7 @@ class TestOssViewset(EndpointTester):
self.assertEqual(new_operation['alias'], data['item_data']['alias']) self.assertEqual(new_operation['alias'], data['item_data']['alias'])
self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type']) self.assertEqual(new_operation['operation_type'], data['item_data']['operation_type'])
self.assertEqual(new_operation['title'], data['item_data']['title']) self.assertEqual(new_operation['title'], data['item_data']['title'])
self.assertEqual(new_operation['comment'], data['item_data']['comment']) self.assertEqual(new_operation['description'], data['item_data']['description'])
self.assertEqual(new_operation['position_x'], data['item_data']['position_x']) self.assertEqual(new_operation['position_x'], data['item_data']['position_x'])
self.assertEqual(new_operation['position_y'], data['item_data']['position_y']) self.assertEqual(new_operation['position_y'], data['item_data']['position_y'])
self.assertEqual(new_operation['result'], None) self.assertEqual(new_operation['result'], None)
@ -223,7 +223,7 @@ class TestOssViewset(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test4', 'alias': 'Test4',
'title': 'Test title', 'title': 'Test title',
'comment': 'Comment', 'description': 'Comment',
'operation_type': OperationType.INPUT, 'operation_type': OperationType.INPUT,
'result': self.ks1.model.pk 'result': self.ks1.model.pk
}, },
@ -238,7 +238,7 @@ class TestOssViewset(EndpointTester):
schema = LibraryItem.objects.get(pk=new_operation['result']) schema = LibraryItem.objects.get(pk=new_operation['result'])
self.assertEqual(schema.alias, data['item_data']['alias']) self.assertEqual(schema.alias, data['item_data']['alias'])
self.assertEqual(schema.title, data['item_data']['title']) self.assertEqual(schema.title, data['item_data']['title'])
self.assertEqual(schema.comment, data['item_data']['comment']) self.assertEqual(schema.description, data['item_data']['description'])
self.assertEqual(schema.visible, False) self.assertEqual(schema.visible, False)
self.assertEqual(schema.access_policy, self.owned.model.access_policy) self.assertEqual(schema.access_policy, self.owned.model.access_policy)
self.assertEqual(schema.location, self.owned.model.location) self.assertEqual(schema.location, self.owned.model.location)
@ -286,7 +286,7 @@ class TestOssViewset(EndpointTester):
self.executeBadData(data=data, item=self.owned_id) self.executeBadData(data=data, item=self.owned_id)
self.operation1.result = None self.operation1.result = None
self.operation1.comment = 'TestComment' self.operation1.description = 'TestComment'
self.operation1.title = 'TestTitle' self.operation1.title = 'TestTitle'
self.operation1.save() self.operation1.save()
response = self.executeOK(data=data) response = self.executeOK(data=data)
@ -296,7 +296,7 @@ class TestOssViewset(EndpointTester):
self.assertEqual(new_schema['id'], self.operation1.result.pk) self.assertEqual(new_schema['id'], self.operation1.result.pk)
self.assertEqual(new_schema['alias'], self.operation1.alias) self.assertEqual(new_schema['alias'], self.operation1.alias)
self.assertEqual(new_schema['title'], self.operation1.title) self.assertEqual(new_schema['title'], self.operation1.title)
self.assertEqual(new_schema['comment'], self.operation1.comment) self.assertEqual(new_schema['description'], self.operation1.description)
data['target'] = self.operation3.pk data['target'] = self.operation3.pk
self.executeBadData(data=data) self.executeBadData(data=data)
@ -326,14 +326,14 @@ class TestOssViewset(EndpointTester):
data['input'] = self.ks1.model.pk data['input'] = self.ks1.model.pk
self.ks1.model.alias = 'Test42' self.ks1.model.alias = 'Test42'
self.ks1.model.title = 'Test421' self.ks1.model.title = 'Test421'
self.ks1.model.comment = 'TestComment42' self.ks1.model.description = 'TestComment42'
self.ks1.save() self.ks1.save()
response = self.executeOK(data=data) response = self.executeOK(data=data)
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.assertEqual(self.operation1.result, self.ks1.model) self.assertEqual(self.operation1.result, self.ks1.model)
self.assertEqual(self.operation1.alias, self.ks1.model.alias) self.assertEqual(self.operation1.alias, self.ks1.model.alias)
self.assertEqual(self.operation1.title, self.ks1.model.title) self.assertEqual(self.operation1.title, self.ks1.model.title)
self.assertEqual(self.operation1.comment, self.ks1.model.comment) self.assertEqual(self.operation1.description, self.ks1.model.description)
@decl_endpoint('/api/oss/{item}/set-input', method='patch') @decl_endpoint('/api/oss/{item}/set-input', method='patch')
def test_set_input_change_schema(self): def test_set_input_change_schema(self):
@ -382,7 +382,7 @@ class TestOssViewset(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test3 mod', 'alias': 'Test3 mod',
'title': 'Test title mod', 'title': 'Test title mod',
'comment': 'Comment mod' 'description': 'Comment mod'
}, },
'positions': [], 'positions': [],
'arguments': [self.operation2.pk, self.operation1.pk], 'arguments': [self.operation2.pk, self.operation1.pk],
@ -406,7 +406,7 @@ class TestOssViewset(EndpointTester):
self.operation3.refresh_from_db() self.operation3.refresh_from_db()
self.assertEqual(self.operation3.alias, data['item_data']['alias']) self.assertEqual(self.operation3.alias, data['item_data']['alias'])
self.assertEqual(self.operation3.title, data['item_data']['title']) self.assertEqual(self.operation3.title, data['item_data']['title'])
self.assertEqual(self.operation3.comment, data['item_data']['comment']) self.assertEqual(self.operation3.description, data['item_data']['description'])
args = self.operation3.getQ_arguments().order_by('order') args = self.operation3.getQ_arguments().order_by('order')
self.assertEqual(args[0].argument.pk, data['arguments'][0]) self.assertEqual(args[0].argument.pk, data['arguments'][0])
self.assertEqual(args[0].order, 0) self.assertEqual(args[0].order, 0)
@ -426,7 +426,7 @@ class TestOssViewset(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test3 mod', 'alias': 'Test3 mod',
'title': 'Test title mod', 'title': 'Test title mod',
'comment': 'Comment mod' 'description': 'Comment mod'
}, },
'positions': [], 'positions': [],
} }
@ -435,10 +435,10 @@ class TestOssViewset(EndpointTester):
self.operation1.refresh_from_db() self.operation1.refresh_from_db()
self.assertEqual(self.operation1.alias, data['item_data']['alias']) self.assertEqual(self.operation1.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.title, data['item_data']['title']) self.assertEqual(self.operation1.title, data['item_data']['title'])
self.assertEqual(self.operation1.comment, data['item_data']['comment']) self.assertEqual(self.operation1.description, data['item_data']['description'])
self.assertEqual(self.operation1.result.alias, data['item_data']['alias']) self.assertEqual(self.operation1.result.alias, data['item_data']['alias'])
self.assertEqual(self.operation1.result.title, data['item_data']['title']) self.assertEqual(self.operation1.result.title, data['item_data']['title'])
self.assertEqual(self.operation1.result.comment, data['item_data']['comment']) self.assertEqual(self.operation1.result.description, data['item_data']['description'])
@decl_endpoint('/api/oss/{item}/update-operation', method='patch') @decl_endpoint('/api/oss/{item}/update-operation', method='patch')
def test_update_operation_invalid_substitution(self): def test_update_operation_invalid_substitution(self):
@ -451,7 +451,7 @@ class TestOssViewset(EndpointTester):
'item_data': { 'item_data': {
'alias': 'Test3 mod', 'alias': 'Test3 mod',
'title': 'Test title mod', 'title': 'Test title mod',
'comment': 'Comment mod' 'description': 'Comment mod'
}, },
'positions': [], 'positions': [],
'arguments': [self.operation1.pk, self.operation2.pk], 'arguments': [self.operation1.pk, self.operation2.pk],
@ -490,7 +490,7 @@ class TestOssViewset(EndpointTester):
self.operation3.refresh_from_db() self.operation3.refresh_from_db()
schema = self.operation3.result schema = self.operation3.result
self.assertEqual(schema.alias, self.operation3.alias) self.assertEqual(schema.alias, self.operation3.alias)
self.assertEqual(schema.comment, self.operation3.comment) self.assertEqual(schema.description, self.operation3.description)
self.assertEqual(schema.title, self.operation3.title) self.assertEqual(schema.title, self.operation3.title)
self.assertEqual(schema.visible, False) self.assertEqual(schema.visible, False)
items = list(RSForm(schema).constituents()) items = list(RSForm(schema).constituents())

View File

@ -200,9 +200,9 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
operation: m.Operation = cast(m.Operation, serializer.validated_data['target']) operation: m.Operation = cast(m.Operation, serializer.validated_data['target'])
if operation.operation_type != m.OperationType.INPUT: if len(operation.getQ_arguments()) > 0:
raise serializers.ValidationError({ raise serializers.ValidationError({
'target': msg.operationNotInput(operation.alias) 'target': msg.operationHasArguments(operation.alias)
}) })
if operation.result is not None: if operation.result is not None:
raise serializers.ValidationError({ raise serializers.ValidationError({
@ -295,15 +295,15 @@ class OssViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retriev
oss.update_positions(serializer.validated_data['positions']) oss.update_positions(serializer.validated_data['positions'])
operation.alias = serializer.validated_data['item_data']['alias'] operation.alias = serializer.validated_data['item_data']['alias']
operation.title = serializer.validated_data['item_data']['title'] operation.title = serializer.validated_data['item_data']['title']
operation.comment = serializer.validated_data['item_data']['comment'] operation.description = serializer.validated_data['item_data']['description']
operation.save(update_fields=['alias', 'title', 'comment']) operation.save(update_fields=['alias', 'title', 'description'])
if operation.result is not None: if operation.result is not None:
can_edit = permissions.can_edit_item(request.user, operation.result) can_edit = permissions.can_edit_item(request.user, operation.result)
if can_edit or operation.operation_type == m.OperationType.SYNTHESIS: if can_edit or operation.operation_type == m.OperationType.SYNTHESIS:
operation.result.alias = operation.alias operation.result.alias = operation.alias
operation.result.title = operation.title operation.result.title = operation.title
operation.result.comment = operation.comment operation.result.description = operation.description
operation.result.save() operation.result.save()
if 'arguments' in serializer.validated_data: if 'arguments' in serializer.validated_data:
oss.set_arguments(operation.pk, serializer.validated_data['arguments']) oss.set_arguments(operation.pk, serializer.validated_data['arguments'])

View File

@ -42,7 +42,7 @@ class RSFormTRSSerializer(serializers.Serializer):
'type': _TRS_TYPE, 'type': _TRS_TYPE,
'title': schema.title, 'title': schema.title,
'alias': schema.alias, 'alias': schema.alias,
'comment': schema.comment, 'comment': schema.description,
'items': [], 'items': [],
'claimed': False, 'claimed': False,
'selection': [], 'selection': [],
@ -78,7 +78,7 @@ class RSFormTRSSerializer(serializers.Serializer):
'type': _TRS_TYPE, 'type': _TRS_TYPE,
'title': data['title'], 'title': data['title'],
'alias': data['alias'], 'alias': data['alias'],
'comment': data['comment'], 'comment': data['description'],
'items': [], 'items': [],
'claimed': False, 'claimed': False,
'selection': [], 'selection': [],
@ -123,7 +123,7 @@ class RSFormTRSSerializer(serializers.Serializer):
if self.context['load_meta']: if self.context['load_meta']:
result['title'] = data.get('title', 'Без названия') result['title'] = data.get('title', 'Без названия')
result['alias'] = data.get('alias', '') result['alias'] = data.get('alias', '')
result['comment'] = data.get('comment', '') result['description'] = data.get('description', '')
if 'id' in data: if 'id' in data:
result['id'] = data['id'] result['id'] = data['id']
self.instance = RSForm.from_id(result['id']) self.instance = RSForm.from_id(result['id'])
@ -144,7 +144,7 @@ class RSFormTRSSerializer(serializers.Serializer):
owner=validated_data.get('owner', None), owner=validated_data.get('owner', None),
alias=validated_data['alias'], alias=validated_data['alias'],
title=validated_data['title'], title=validated_data['title'],
comment=validated_data['comment'], description=validated_data['description'],
visible=validated_data['visible'], visible=validated_data['visible'],
read_only=validated_data['read_only'], read_only=validated_data['read_only'],
access_policy=validated_data['access_policy'], access_policy=validated_data['access_policy'],
@ -171,8 +171,8 @@ class RSFormTRSSerializer(serializers.Serializer):
instance.model.alias = validated_data['alias'] instance.model.alias = validated_data['alias']
if 'title' in validated_data: if 'title' in validated_data:
instance.model.title = validated_data['title'] instance.model.title = validated_data['title']
if 'comment' in validated_data: if 'description' in validated_data:
instance.model.comment = validated_data['comment'] instance.model.description = validated_data['description']
order = 0 order = 0
prev_constituents = instance.constituents() prev_constituents = instance.constituents()

View File

@ -30,7 +30,7 @@ class TestRSFormViewset(EndpointTester):
work_dir = os.path.dirname(os.path.abspath(__file__)) work_dir = os.path.dirname(os.path.abspath(__file__))
data = { data = {
'title': 'Test123', 'title': 'Test123',
'comment': '123', 'description': '123',
'alias': 'ks1', 'alias': 'ks1',
'location': LocationHead.PROJECTS, 'location': LocationHead.PROJECTS,
'access_policy': AccessPolicy.PROTECTED, 'access_policy': AccessPolicy.PROTECTED,
@ -45,7 +45,7 @@ class TestRSFormViewset(EndpointTester):
self.assertEqual(response.data['owner'], self.user.pk) self.assertEqual(response.data['owner'], self.user.pk)
self.assertEqual(response.data['title'], data['title']) self.assertEqual(response.data['title'], data['title'])
self.assertEqual(response.data['alias'], data['alias']) self.assertEqual(response.data['alias'], data['alias'])
self.assertEqual(response.data['comment'], data['comment']) self.assertEqual(response.data['description'], data['description'])
@decl_endpoint('/api/rsforms', method='get') @decl_endpoint('/api/rsforms', method='get')

View File

@ -586,8 +586,8 @@ def _prepare_rsform_data(data: dict, request: Request, owner: Union[User, None])
data['title'] = 'Без названия ' + request.FILES['file'].fileName data['title'] = 'Без названия ' + request.FILES['file'].fileName
if 'alias' in request.data and request.data['alias'] != '': if 'alias' in request.data and request.data['alias'] != '':
data['alias'] = request.data['alias'] data['alias'] = request.data['alias']
if 'comment' in request.data and request.data['comment'] != '': if 'description' in request.data and request.data['description'] != '':
data['comment'] = request.data['comment'] data['description'] = request.data['description']
visible = True visible = True
if 'visible' in request.data: if 'visible' in request.data:

View File

@ -1 +1,27 @@
''' Admin: User profile and Authorization. ''' ''' Admin: User profile and Authorization. '''
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
User = get_user_model()
class CustomUserAdmin(UserAdmin):
''' Admin model: User. '''
fieldsets = UserAdmin.fieldsets
list_display = (
'username',
'email',
'first_name',
'last_name',
'is_staff',
'is_active',
'date_joined',
'last_login')
ordering = ['date_joined', 'username']
search_fields = ['email', 'first_name', 'last_name', 'username']
list_filter = ['is_staff', 'is_superuser', 'is_active']
admin.site.unregister(User)
admin.site.register(User, CustomUserAdmin)

View File

@ -88,7 +88,7 @@
"owner": 1, "owner": 1,
"title": "Банк выражений", "title": "Банк выражений",
"alias": "БВ", "alias": "БВ",
"comment": "Банк шаблонов для генерации выражений", "description": "Банк шаблонов для генерации выражений",
"visible": true, "visible": true,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -105,7 +105,7 @@
"owner": 5, "owner": 5,
"title": "Групповая операция", "title": "Групповая операция",
"alias": К09", "alias": К09",
"comment": "", "description": "",
"visible": true, "visible": true,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -122,7 +122,7 @@
"owner": 3, "owner": 3,
"title": "Булева алгебра", "title": "Булева алгебра",
"alias": К12", "alias": К12",
"comment": "", "description": "",
"visible": true, "visible": true,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -139,7 +139,7 @@
"owner": 3, "owner": 3,
"title": "Генеалогия", "title": "Генеалогия",
"alias": "D0001", "alias": "D0001",
"comment": "построено на основе понятия \"родство\" из Википедии", "description": "построено на основе понятия \"родство\" из Википедии",
"visible": true, "visible": true,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -156,7 +156,7 @@
"owner": 1, "owner": 1,
"title": "Вещества и смеси", "title": "Вещества и смеси",
"alias": "КС Вещества", "alias": "КС Вещества",
"comment": "", "description": "",
"visible": false, "visible": false,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -173,7 +173,7 @@
"owner": 1, "owner": 1,
"title": "Объект-объектные отношения", "title": "Объект-объектные отношения",
"alias": "КС ООО", "alias": "КС ООО",
"comment": "", "description": "",
"visible": false, "visible": false,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -190,7 +190,7 @@
"owner": 1, "owner": 1,
"title": "Процессы", "title": "Процессы",
"alias": "КС Процессы", "alias": "КС Процессы",
"comment": "", "description": "",
"visible": false, "visible": false,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -207,7 +207,7 @@
"owner": 1, "owner": 1,
"title": "Экологические правоотношения", "title": "Экологические правоотношения",
"alias": "ЭКОС", "alias": "ЭКОС",
"comment": "", "description": "",
"visible": true, "visible": true,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -224,7 +224,7 @@
"owner": 1, "owner": 1,
"title": "Объектная среда", "title": "Объектная среда",
"alias": "КС Объект-сред", "alias": "КС Объект-сред",
"comment": "", "description": "",
"visible": false, "visible": false,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -241,7 +241,7 @@
"owner": 1, "owner": 1,
"title": "Процессные среды", "title": "Процессные среды",
"alias": "КС Проц-сред", "alias": "КС Проц-сред",
"comment": "", "description": "",
"visible": false, "visible": false,
"read_only": false, "read_only": false,
"access_policy": "public", "access_policy": "public",
@ -7414,7 +7414,7 @@
"result": 38, "result": 38,
"alias": "КС Вещества", "alias": "КС Вещества",
"title": "Вещества и смеси", "title": "Вещества и смеси",
"comment": "", "description": "",
"position_x": 530.0, "position_x": 530.0,
"position_y": 370.0 "position_y": 370.0
} }
@ -7428,7 +7428,7 @@
"result": 39, "result": 39,
"alias": "КС ООО", "alias": "КС ООО",
"title": "Объект-объектные отношения", "title": "Объект-объектные отношения",
"comment": "", "description": "",
"position_x": 710.0, "position_x": 710.0,
"position_y": 370.0 "position_y": 370.0
} }
@ -7442,7 +7442,7 @@
"result": 40, "result": 40,
"alias": "КС Процессы", "alias": "КС Процессы",
"title": "Процессы", "title": "Процессы",
"comment": "", "description": "",
"position_x": 890.0, "position_x": 890.0,
"position_y": 370.0 "position_y": 370.0
} }
@ -7456,7 +7456,7 @@
"result": 43, "result": 43,
"alias": "КС Объект-сред", "alias": "КС Объект-сред",
"title": "Объектная среда", "title": "Объектная среда",
"comment": "", "description": "",
"position_x": 620.0, "position_x": 620.0,
"position_y": 470.0 "position_y": 470.0
} }
@ -7470,7 +7470,7 @@
"result": 44, "result": 44,
"alias": "КС Проц-сред", "alias": "КС Проц-сред",
"title": "Процессные среды", "title": "Процессные среды",
"comment": "", "description": "",
"position_x": 760.0, "position_x": 760.0,
"position_y": 570.0 "position_y": 570.0
} }

View File

@ -112,6 +112,7 @@ if _domain != '':
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.gzip.GZipMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',

View File

@ -34,6 +34,10 @@ def operationNotInput(title: str):
return f'Операция не является Загрузкой: {title}' return f'Операция не является Загрузкой: {title}'
def operationHasArguments(title: str):
return f'Операция имеет аргументы: {title}'
def operationResultFromAnotherOSS(): def operationResultFromAnotherOSS():
return 'Схема является результатом другой ОСС' return 'Схема является результатом другой ОСС'

View File

@ -0,0 +1,22 @@
{
"extends": ["stylelint-config-recommended", "stylelint-config-standard", "stylelint-config-tailwindcss"],
"rules": {
"color-no-invalid-hex": true,
"font-family-no-missing-generic-family-keyword": true,
"unit-no-unknown": true,
"block-no-empty": true,
"selector-pseudo-element-no-unknown": true,
"property-no-unknown": true,
"declaration-block-no-duplicate-properties": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"import-notation": null,
"at-rule-empty-line-before": null,
"declaration-empty-line-before": null,
"at-rule-no-unknown": null,
"comment-no-empty": null,
"comment-empty-line-before": null,
"custom-property-empty-line-before": null
}
}

View File

@ -8,6 +8,33 @@ import importPlugin from 'eslint-plugin-import';
import simpleImportSort from 'eslint-plugin-simple-import-sort'; import simpleImportSort from 'eslint-plugin-simple-import-sort';
import playwright from 'eslint-plugin-playwright'; import playwright from 'eslint-plugin-playwright';
const basicRules = {
'no-console': 'off',
'require-jsdoc': 'off',
'@typescript-eslint/consistent-type-imports': [
'warn',
{
fixStyle: 'inline-type-imports'
}
],
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
],
'simple-import-sort/exports': 'error',
'import/no-duplicates': 'warn'
};
export default [ export default [
...typescriptPlugin.configs.recommendedTypeChecked, ...typescriptPlugin.configs.recommendedTypeChecked,
...typescriptPlugin.configs.stylisticTypeChecked, ...typescriptPlugin.configs.stylisticTypeChecked,
@ -45,33 +72,9 @@ export default [
}, },
settings: { react: { version: 'detect' } }, settings: { react: { version: 'detect' } },
rules: { rules: {
'no-console': 'off', ...basicRules,
'require-jsdoc': 'off',
'react-compiler/react-compiler': 'error', 'react-compiler/react-compiler': 'error',
'react-refresh/only-export-components': ['off', { allowConstantExport: true }], 'react-refresh/only-export-components': ['off', { allowConstantExport: true }],
'@typescript-eslint/consistent-type-imports': [
'warn',
{
fixStyle: 'inline-type-imports'
}
],
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
],
'simple-import-sort/exports': 'error',
'import/no-duplicates': 'warn',
'simple-import-sort/imports': [ 'simple-import-sort/imports': [
'warn', 'warn',
{ {
@ -113,13 +116,8 @@ export default [
}, },
rules: { rules: {
...basicRules,
...playwright.configs['flat/recommended'].rules, ...playwright.configs['flat/recommended'].rules,
'no-console': 'off',
'require-jsdoc': 'off',
'simple-import-sort/exports': 'error',
'import/no-duplicates': 'warn',
'simple-import-sort/imports': 'warn' 'simple-import-sort/imports': 'warn'
} }
} }

View File

@ -12,12 +12,16 @@
<meta name="google-site-verification" content="bodB0xvBD_xM-VHg7EgfTf87jEMBF1DriZKdrZjwW1k" /> <meta name="google-site-verification" content="bodB0xvBD_xM-VHg7EgfTf87jEMBF1DriZKdrZjwW1k" />
<meta name="yandex-verification" content="2b1f1f721cd6b66a" /> <meta name="yandex-verification" content="2b1f1f721cd6b66a" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
rel="preload" rel="preload"
as="style" as="style"
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Fira+Code:wght@300..700&family=Noto+Sans+Math&family=Noto+Sans+Symbols+2&family=Alegreya+Sans+SC:wght@100;300;400;500;700;800;900&family=Noto+Color+Emoji&display=block" href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;1,400;1,600&family=Fira+Code:wght@400;500;600&family=Noto+Sans+Math&family=Noto+Sans+Symbols+2&family=Alegreya+Sans+SC:wght@400;700&family=Noto+Color+Emoji&display=swap"
onload="this.onload=null;this.rel='stylesheet'" onload="this.onload=null;this.rel='stylesheet'"
/> />

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint": "stylelint \"src/**/*.css\" && eslint . --report-unused-disable-directives --max-warnings 0",
"lintFix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix", "lintFix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix",
"preview": "vite preview --port 3000" "preview": "vite preview --port 3000"
}, },
@ -17,12 +17,12 @@
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^4.1.3",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.67.2", "@tanstack/react-query": "^5.69.0",
"@tanstack/react-query-devtools": "^5.67.2", "@tanstack/react-query-devtools": "^5.69.0",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@uiw/codemirror-themes": "^4.23.10", "@uiw/codemirror-themes": "^4.23.10",
"@uiw/react-codemirror": "^4.23.10", "@uiw/react-codemirror": "^4.23.10",
"axios": "^1.8.2", "axios": "^1.8.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"global": "^4.4.0", "global": "^4.4.0",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
@ -34,7 +34,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-intl": "^7.1.6", "react-intl": "^7.1.6",
"react-router": "^7.3.0", "react-router": "^7.3.0",
"react-scan": "^0.2.14", "react-scan": "^0.3.2",
"react-select": "^5.10.1", "react-select": "^5.10.1",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
@ -47,11 +47,11 @@
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.2", "@lezer/generator": "^1.7.2",
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.1",
"@tailwindcss/vite": "^4.0.12", "@tailwindcss/vite": "^4.0.14",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.0.1",
@ -66,11 +66,15 @@
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"stylelint": "^16.16.0",
"stylelint-config-recommended": "^15.0.0",
"stylelint-config-standard": "^37.0.0",
"stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"ts-jest": "^29.2.6", "ts-jest": "^29.2.6",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.26.0", "typescript-eslint": "^8.26.1",
"vite": "^6.2.1" "vite": "^6.2.2"
}, },
"overrides": { "overrides": {
"react": "^19.0.0" "react": "^19.0.0"

View File

@ -15,20 +15,13 @@ export function NavigationButton({ icon, title, hideTitle, className, style, onC
return ( return (
<button <button
type='button' type='button'
tabIndex={-1} tabIndex={0}
aria-label={title} aria-label={title}
data-tooltip-id={!!title ? globalIDs.tooltip : undefined} data-tooltip-id={!!title ? globalIDs.tooltip : undefined}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
data-tooltip-content={title} data-tooltip-content={title}
onClick={onClick} onClick={onClick}
className={clsx( className={clsx('p-2 flex items-center gap-1', 'cc-btn-nav', 'font-controls focus-outline', className)}
'p-2 flex items-center gap-1',
'cursor-pointer',
'clr-btn-nav cc-animate-color duration-500',
'rounded-xl',
'font-controls whitespace-nowrap',
className
)}
style={style} style={style}
> >
{icon ? icon : null} {icon ? icon : null}

View File

@ -22,7 +22,7 @@ interface INavigationContext {
setRequireConfirmation: (value: boolean) => void; setRequireConfirmation: (value: boolean) => void;
} }
export const NavigationContext = createContext<INavigationContext | null>(null); const NavigationContext = createContext<INavigationContext | null>(null);
export const useConceptNavigation = () => { export const useConceptNavigation = () => {
const context = use(NavigationContext); const context = use(NavigationContext);
if (!context) { if (!context) {

View File

@ -13,25 +13,25 @@ import { ToggleNavigation } from './toggle-navigation';
import { UserMenu } from './user-menu'; import { UserMenu } from './user-menu';
export function Navigation() { export function Navigation() {
const router = useConceptNavigation(); const { push } = useConceptNavigation();
const size = useWindowSize(); const size = useWindowSize();
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const navigateHome = (event: React.MouseEvent<Element>) => const navigateHome = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.home, newTab: event.ctrlKey || event.metaKey }); push({ path: urls.home, newTab: event.ctrlKey || event.metaKey });
const navigateLibrary = (event: React.MouseEvent<Element>) => const navigateLibrary = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.library, newTab: event.ctrlKey || event.metaKey }); push({ path: urls.library, newTab: event.ctrlKey || event.metaKey });
const navigateHelp = (event: React.MouseEvent<Element>) => const navigateHelp = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.manuals, newTab: event.ctrlKey || event.metaKey }); push({ path: urls.manuals, newTab: event.ctrlKey || event.metaKey });
const navigateCreateNew = (event: React.MouseEvent<Element>) => const navigateCreateNew = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey }); push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey });
return ( return (
<nav className='z-navigation sticky top-0 left-0 right-0 select-none bg-prim-100'> <nav className='z-navigation sticky top-0 left-0 right-0 select-none bg-prim-100'>
<ToggleNavigation /> <ToggleNavigation />
<div <div
className={clsx( className={clsx(
'pl-2 pr-6 sm:pr-4 h-12 flex cc-shadow-border', 'pl-2 sm:pr-4 h-12 flex cc-shadow-border',
'transition-[max-height,translate] ease-bezier duration-(--duration-move)', 'transition-[max-height,translate] ease-bezier duration-(--duration-move)',
noNavigationAnimation ? '-translate-y-6 max-h-0' : 'max-h-12' noNavigationAnimation ? '-translate-y-6 max-h-0' : 'max-h-12'
)} )}

View File

@ -1,5 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx';
import { IconDarkTheme, IconLightTheme, IconPin, IconUnpin } from '@/components/icons'; import { IconDarkTheme, IconLightTheme, IconPin, IconUnpin } from '@/components/icons';
import { useAppLayoutStore } from '@/stores/app-layout'; import { useAppLayoutStore } from '@/stores/app-layout';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
@ -11,7 +13,9 @@ export function ToggleNavigation() {
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const toggleNoNavigation = useAppLayoutStore(state => state.toggleNoNavigation); const toggleNoNavigation = useAppLayoutStore(state => state.toggleNoNavigation);
return ( return (
<div className='absolute top-0 right-0 z-navigation h-12 grid'> <div
className={clsx('absolute top-0 right-0 z-navigation h-12', noNavigationAnimation ? 'grid' : 'hidden sm:grid')}
>
<button <button
tabIndex={-1} tabIndex={-1}
type='button' type='button'
@ -19,6 +23,7 @@ export function ToggleNavigation() {
onClick={toggleNoNavigation} onClick={toggleNoNavigation}
data-tooltip-id={globalIDs.tooltip} data-tooltip-id={globalIDs.tooltip}
data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'} data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
aria-label={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
> >
{!noNavigationAnimation ? <IconPin size='0.75rem' /> : null} {!noNavigationAnimation ? <IconPin size='0.75rem' /> : null}
{noNavigationAnimation ? <IconUnpin size='0.75rem' /> : null} {noNavigationAnimation ? <IconUnpin size='0.75rem' /> : null}
@ -31,6 +36,7 @@ export function ToggleNavigation() {
onClick={toggleDarkMode} onClick={toggleDarkMode}
data-tooltip-id={globalIDs.tooltip} data-tooltip-id={globalIDs.tooltip}
data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'} data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
aria-label={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
> >
{darkMode ? <IconDarkTheme size='0.75rem' /> : null} {darkMode ? <IconDarkTheme size='0.75rem' /> : null}
{!darkMode ? <IconLightTheme size='0.75rem' /> : null} {!darkMode ? <IconLightTheme size='0.75rem' /> : null}

View File

@ -85,27 +85,28 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
/> />
<DropdownButton <DropdownButton
text={darkMode ? 'Тема: Темная' : 'Тема: Светлая'} text={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
icon={darkMode ? <IconDarkTheme size='1rem' /> : <IconLightTheme size='1rem' />}
title='Переключение темы оформления' title='Переключение темы оформления'
icon={darkMode ? <IconDarkTheme size='1rem' /> : <IconLightTheme size='1rem' />}
onClick={handleToggleDarkMode} onClick={handleToggleDarkMode}
/> />
<DropdownButton <DropdownButton
text={showHelp ? 'Помощь: Вкл' : 'Помощь: Выкл'} text={showHelp ? 'Помощь: Вкл' : 'Помощь: Выкл'}
icon={showHelp ? <IconHelp size='1rem' /> : <IconHelpOff size='1rem' />}
title='Отображение иконок подсказок' title='Отображение иконок подсказок'
icon={showHelp ? <IconHelp size='1rem' /> : <IconHelpOff size='1rem' />}
onClick={toggleShowHelp} onClick={toggleShowHelp}
/> />
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text={adminMode ? 'Админ: Вкл' : 'Админ: Выкл'} text={adminMode ? 'Админ: Вкл' : 'Админ: Выкл'}
icon={adminMode ? <IconAdmin size='1rem' /> : <IconAdminOff size='1rem' />}
title='Работа в режиме администратора' title='Работа в режиме администратора'
icon={adminMode ? <IconAdmin size='1rem' /> : <IconAdminOff size='1rem' />}
onClick={toggleAdminMode} onClick={toggleAdminMode}
/> />
) : null} ) : null}
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text='REST API' // text='REST API' //
title='Переход к backend API'
icon={<IconRESTapi size='1rem' />} icon={<IconRESTapi size='1rem' />}
className='border-t' className='border-t'
onClick={gotoRestApi} onClick={gotoRestApi}
@ -114,6 +115,7 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text='База данных' // text='База данных' //
title='Переход к администрированию базы данных'
icon={<IconDatabase size='1rem' />} icon={<IconDatabase size='1rem' />}
onClick={gotoAdmin} onClick={gotoAdmin}
/> />
@ -121,6 +123,7 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
{user?.is_staff ? ( {user?.is_staff ? (
<DropdownButton <DropdownButton
text='Иконки' // text='Иконки' //
title='Переход к странице иконок'
icon={<IconImage size='1rem' />} icon={<IconImage size='1rem' />}
onClick={gotoIcons} onClick={gotoIcons}
/> />
@ -128,6 +131,7 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
{user.is_staff ? ( {user.is_staff ? (
<DropdownButton <DropdownButton
text='Структура БД' // text='Структура БД' //
title='Переход к странице структуры БД'
icon={<IconDBStructure size='1rem' />} icon={<IconDBStructure size='1rem' />}
onClick={gotoDatabaseSchema} onClick={gotoDatabaseSchema}
className='border-b' className='border-b'
@ -135,6 +139,7 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
) : null} ) : null}
<DropdownButton <DropdownButton
text='Выйти...' text='Выйти...'
title='Выход из приложения'
className='font-semibold' className='font-semibold'
icon={<IconLogout size='1rem' />} icon={<IconLogout size='1rem' />}
onClick={logoutAndRedirect} onClick={logoutAndRedirect}

View File

@ -33,19 +33,19 @@ axiosInstance.interceptors.request.use(config => {
}); });
// ================ Data transfer types ================ // ================ Data transfer types ================
export interface IFrontRequest<RequestData, ResponseData> { interface IFrontRequest<RequestData, ResponseData> {
data?: RequestData; data?: RequestData;
successMessage?: string | ((data: ResponseData) => string); successMessage?: string | ((data: ResponseData) => string);
} }
export interface IAxiosRequest<RequestData, ResponseData> { interface IAxiosRequest<RequestData, ResponseData> {
endpoint: string; endpoint: string;
request?: IFrontRequest<RequestData, ResponseData>; request?: IFrontRequest<RequestData, ResponseData>;
options?: AxiosRequestConfig; options?: AxiosRequestConfig;
schema?: z.ZodType; schema?: z.ZodType;
} }
export interface IAxiosGetRequest { interface IAxiosGetRequest {
endpoint: string; endpoint: string;
options?: AxiosRequestConfig; options?: AxiosRequestConfig;
signal?: AbortSignal; signal?: AbortSignal;

View File

@ -38,14 +38,13 @@ export function Button({
return ( return (
<button <button
type='button' type='button'
disabled={disabled ?? loading}
className={clsx( className={clsx(
'inline-flex gap-2 items-center justify-center', 'inline-flex gap-2 items-center justify-center',
'font-medium select-none disabled:cursor-auto', 'font-medium select-none disabled:cursor-auto',
'clr-btn-default cc-animate-color', 'clr-btn-default cc-animate-color',
dense ? 'px-1' : 'px-3 py-1', dense ? 'px-1' : 'px-3 py-1',
loading ? 'cursor-progress' : 'cursor-pointer', loading ? 'cursor-progress' : 'cursor-pointer',
noOutline ? 'outline-hidden' : 'clr-outline', noOutline ? 'outline-hidden' : 'focus-outline',
!noBorder && 'border rounded-sm', !noBorder && 'border rounded-sm',
className className
)} )}
@ -53,6 +52,8 @@ export function Button({
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
disabled={disabled ?? loading}
aria-label={!text ? title : undefined}
{...restProps} {...restProps}
> >
{icon ? icon : null} {icon ? icon : null}

View File

@ -49,6 +49,7 @@ export function MiniButton({
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
aria-label={title}
{...restProps} {...restProps}
> >
{icon} {icon}

View File

@ -21,7 +21,7 @@ import { TableFooter } from './table-footer';
import { TableHeader } from './table-header'; import { TableHeader } from './table-header';
import { useDataTable } from './use-data-table'; import { useDataTable } from './use-data-table';
export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState }; export { createColumnHelper, type RowSelectionState, type VisibilityState };
/** Style to conditionally apply to rows. */ /** Style to conditionally apply to rows. */
export interface IConditionalStyle<TData> { export interface IConditionalStyle<TData> {
@ -120,6 +120,7 @@ export function DataTable<TData extends RowData>({
onRowDoubleClicked, onRowDoubleClicked,
noDataComponent, noDataComponent,
onChangePaginationOption,
paginationPerPage, paginationPerPage,
paginationOptions = [10, 20, 30, 40, 50], paginationOptions = [10, 20, 30, 40, 50],
@ -182,6 +183,7 @@ export function DataTable<TData extends RowData>({
<PaginationTools <PaginationTools
id={id ? `${id}__pagination` : undefined} id={id ? `${id}__pagination` : undefined}
table={table} table={table}
onChangePaginationOption={onChangePaginationOption}
paginationOptions={paginationOptions} paginationOptions={paginationOptions}
/> />
) : null} ) : null}

View File

@ -12,15 +12,22 @@ interface PaginationToolsProps<TData> {
id?: string; id?: string;
table: Table<TData>; table: Table<TData>;
paginationOptions: number[]; paginationOptions: number[];
onChangePaginationOption?: (newValue: number) => void;
} }
export function PaginationTools<TData>({ id, table, paginationOptions }: PaginationToolsProps<TData>) { export function PaginationTools<TData>({
id,
table,
onChangePaginationOption,
paginationOptions
}: PaginationToolsProps<TData>) {
const handlePaginationOptionsChange = useCallback( const handlePaginationOptionsChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => { (event: React.ChangeEvent<HTMLSelectElement>) => {
const perPage = Number(event.target.value); const perPage = Number(event.target.value);
table.setPageSize(perPage); table.setPageSize(perPage);
onChangePaginationOption?.(perPage);
}, },
[table] [table, onChangePaginationOption]
); );
return ( return (
@ -38,7 +45,8 @@ export function PaginationTools<TData>({ id, table, paginationOptions }: Paginat
<div className='flex'> <div className='flex'>
<button <button
type='button' type='button'
className='clr-hover clr-text-controls cc-animate-color' aria-label='Первая страница'
className='clr-hover clr-text-controls cc-animate-color focus-outline'
onClick={() => table.setPageIndex(0)} onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@ -46,7 +54,8 @@ export function PaginationTools<TData>({ id, table, paginationOptions }: Paginat
</button> </button>
<button <button
type='button' type='button'
className='clr-hover clr-text-controls cc-animate-color' aria-label='Предыдущая страница'
className='clr-hover clr-text-controls cc-animate-color focus-outline'
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@ -55,7 +64,8 @@ export function PaginationTools<TData>({ id, table, paginationOptions }: Paginat
<input <input
id={id ? `${id}__page` : undefined} id={id ? `${id}__page` : undefined}
title='Номер страницы. Выделите для ручного ввода' title='Номер страницы. Выделите для ручного ввода'
className='w-6 text-center bg-prim-100' aria-label='Номер страницы'
className='w-6 text-center bg-prim-100 focus-outline'
value={table.getState().pagination.pageIndex + 1} value={table.getState().pagination.pageIndex + 1}
onChange={event => { onChange={event => {
const page = event.target.value ? Number(event.target.value) - 1 : 0; const page = event.target.value ? Number(event.target.value) - 1 : 0;
@ -66,7 +76,8 @@ export function PaginationTools<TData>({ id, table, paginationOptions }: Paginat
/> />
<button <button
type='button' type='button'
className='clr-hover clr-text-controls cc-animate-color' aria-label='Следующая страница'
className='clr-hover clr-text-controls cc-animate-color focus-outline'
onClick={() => table.nextPage()} onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@ -74,7 +85,8 @@ export function PaginationTools<TData>({ id, table, paginationOptions }: Paginat
</button> </button>
<button <button
type='button' type='button'
className='clr-hover clr-text-controls cc-animate-color' aria-label='Последняя страница'
className='clr-hover clr-text-controls cc-animate-color focus-outline'
onClick={() => table.setPageIndex(table.getPageCount() - 1)} onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@ -83,12 +95,13 @@ export function PaginationTools<TData>({ id, table, paginationOptions }: Paginat
</div> </div>
<select <select
id={id ? `${id}__per_page` : undefined} id={id ? `${id}__per_page` : undefined}
aria-label='Выбор количества строчек на странице'
value={table.getState().pagination.pageSize} value={table.getState().pagination.pageSize}
onChange={handlePaginationOptionsChange} onChange={handlePaginationOptionsChange}
className='mx-2 cursor-pointer bg-prim-100' className='mx-2 cursor-pointer bg-prim-100 focus-outline'
> >
{paginationOptions.map(pageSize => ( {paginationOptions.map(pageSize => (
<option key={`${prefixes.page_size}${pageSize}`} value={pageSize}> <option key={`${prefixes.page_size}${pageSize}`} value={pageSize} aria-label={`${pageSize} на страницу`}>
{pageSize} на стр {pageSize} на стр
</option> </option>
))} ))}

View File

@ -33,7 +33,7 @@ export function TableBody<TData>({
const handleRowClicked = useCallback( const handleRowClicked = useCallback(
(target: Row<TData>, event: React.MouseEvent<Element>) => { (target: Row<TData>, event: React.MouseEvent<Element>) => {
onRowClicked?.(target.original, event); onRowClicked?.(target.original, event);
if (target.getCanSelect()) { if (table.options.enableRowSelection && target.getCanSelect()) {
if (event.shiftKey && !!lastSelected && lastSelected !== target.id) { if (event.shiftKey && !!lastSelected && lastSelected !== target.id) {
const { rows, rowsById } = table.getRowModel(); const { rows, rowsById } = table.getRowModel();
const lastIndex = rowsById[lastSelected].index; const lastIndex = rowsById[lastSelected].index;

View File

@ -1,9 +1,8 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useState } from 'react';
import { import {
type ColumnSort, type ColumnSort,
createColumnHelper,
getCoreRowModel, getCoreRowModel,
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
@ -12,13 +11,10 @@ import {
type RowSelectionState, type RowSelectionState,
type SortingState, type SortingState,
type TableOptions, type TableOptions,
type Updater,
useReactTable, useReactTable,
type VisibilityState type VisibilityState
} from '@tanstack/react-table'; } from '@tanstack/react-table';
export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
/** Style to conditionally apply to rows. */ /** Style to conditionally apply to rows. */
export interface IConditionalStyle<TData> { export interface IConditionalStyle<TData> {
/** Callback to determine if the style should be applied. */ /** Callback to determine if the style should be applied. */
@ -28,7 +24,7 @@ export interface IConditionalStyle<TData> {
style: React.CSSProperties; style: React.CSSProperties;
} }
export interface UseDataTableProps<TData extends RowData> interface UseDataTableProps<TData extends RowData>
extends Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> { extends Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> {
/** Enable row selection. */ /** Enable row selection. */
enableRowSelection?: boolean; enableRowSelection?: boolean;
@ -48,9 +44,6 @@ export interface UseDataTableProps<TData extends RowData>
/** Number of rows per page. */ /** Number of rows per page. */
paginationPerPage?: number; paginationPerPage?: number;
/** Callback to be called when the pagination option is changed. */
onChangePaginationOption?: (newValue: number) => void;
/** Enable sorting. */ /** Enable sorting. */
enableSorting?: boolean; enableSorting?: boolean;
@ -76,7 +69,6 @@ export function useDataTable<TData extends RowData>({
enablePagination, enablePagination,
paginationPerPage = 10, paginationPerPage = 10,
onChangePaginationOption,
...restProps ...restProps
}: UseDataTableProps<TData>) { }: UseDataTableProps<TData>) {
@ -86,19 +78,6 @@ export function useDataTable<TData extends RowData>({
pageSize: paginationPerPage pageSize: paginationPerPage
}); });
const handleChangePagination = useCallback(
(updater: Updater<PaginationState>) => {
setPagination(prev => {
const resolvedValue = typeof updater === 'function' ? updater(prev) : updater;
if (onChangePaginationOption && prev.pageSize !== resolvedValue.pageSize) {
onChangePaginationOption(resolvedValue.pageSize);
}
return resolvedValue;
});
},
[onChangePaginationOption]
);
const table = useReactTable({ const table = useReactTable({
state: { state: {
pagination: pagination, pagination: pagination,
@ -114,7 +93,7 @@ export function useDataTable<TData extends RowData>({
onSortingChange: enableSorting ? setSorting : undefined, onSortingChange: enableSorting ? setSorting : undefined,
getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined, getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
onPaginationChange: enablePagination ? handleChangePagination : undefined, onPaginationChange: enablePagination ? setPagination : undefined,
enableHiding: enableHiding, enableHiding: enableHiding,
enableMultiRowSelection: enableRowSelection, enableMultiRowSelection: enableRowSelection,

View File

@ -31,7 +31,6 @@ export function DropdownButton({
}: DropdownButtonProps) { }: DropdownButtonProps) {
return ( return (
<button <button
tabIndex={-1}
type='button' type='button'
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
@ -46,6 +45,7 @@ export function DropdownButton({
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
aria-label={title}
{...restProps} {...restProps}
> >
{icon ? icon : null} {icon ? icon : null}

View File

@ -1,19 +0,0 @@
import clsx from 'clsx';
import { Checkbox, type CheckboxProps } from '../input';
/** Animated {@link Checkbox} inside a {@link Dropdown} item. */
export function DropdownCheckbox({ onChange: setValue, disabled, ...restProps }: CheckboxProps) {
return (
<div
className={clsx(
'px-3 py-1',
'text-left text-ellipsis whitespace-nowrap',
'disabled:clr-text-controls cc-animate-color',
!!setValue && !disabled && 'clr-hover'
)}
>
<Checkbox tabIndex={-1} disabled={disabled} onChange={setValue} {...restProps} />
</div>
);
}

View File

@ -48,6 +48,7 @@ export function Dropdown({
className className
)} )}
aria-hidden={!isOpen} aria-hidden={!isOpen}
inert={!isOpen}
{...restProps} {...restProps}
> >
{children} {children}

View File

@ -1,4 +1,3 @@
export { Dropdown } from './dropdown'; export { Dropdown } from './dropdown';
export { DropdownButton } from './dropdown-button'; export { DropdownButton } from './dropdown-button';
export { DropdownCheckbox } from './dropdown-checkbox';
export { useDropdown } from './use-dropdown'; export { useDropdown } from './use-dropdown';

View File

@ -2,18 +2,21 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useClickedOutside } from '@/hooks/use-clicked-outside';
export function useDropdown() { export function useDropdown() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null); const ref = useRef<HTMLDivElement>(null);
useClickedOutside(isOpen, ref, () => setIsOpen(false)); function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
if (!ref.current?.contains(event.relatedTarget as Node)) {
setIsOpen(false);
}
}
return { return {
ref, ref,
isOpen, isOpen,
setIsOpen, setIsOpen,
handleBlur,
toggle: () => setIsOpen(!isOpen), toggle: () => setIsOpen(!isOpen),
hide: () => setIsOpen(false) hide: () => setIsOpen(false)
}; };

View File

@ -171,9 +171,9 @@ export interface IconProps {
className?: string; className?: string;
} }
function MetaIconSVG({ viewBox, size = '1.5rem', props, children }: React.PropsWithChildren<IconSVGProps>) { function MetaIconSVG({ viewBox, size = '1.5rem', props, className, children }: React.PropsWithChildren<IconSVGProps>) {
return ( return (
<svg width={size} height={size} fill='currentColor' viewBox={viewBox} {...props}> <svg width={size} height={size} fill='currentColor' className={className} viewBox={viewBox} {...props}>
{children} {children}
</svg> </svg>
); );

View File

@ -6,7 +6,7 @@ import { CheckboxChecked, CheckboxNull } from '../icons';
import { type CheckboxProps } from './checkbox'; import { type CheckboxProps } from './checkbox';
export interface CheckboxTristateProps extends Omit<CheckboxProps, 'value' | 'onChange'> { interface CheckboxTristateProps extends Omit<CheckboxProps, 'value' | 'onChange'> {
/** Current value - `null`, `true` or `false`. */ /** Current value - `null`, `true` or `false`. */
value: boolean | null; value: boolean | null;
@ -55,12 +55,12 @@ export function CheckboxTristate({
cursor, cursor,
className className
)} )}
disabled={disabled}
onClick={handleClick} onClick={handleClick}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined} data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
disabled={disabled}
{...restProps} {...restProps}
> >
<div <div

View File

@ -54,12 +54,12 @@ export function Checkbox({
cursor, cursor,
className className
)} )}
disabled={disabled}
onClick={handleClick} onClick={handleClick}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined} data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
disabled={disabled}
{...restProps} {...restProps}
> >
<div <div

View File

@ -5,7 +5,7 @@ export { FileInput } from './file-input';
export { Label } from './label'; export { Label } from './label';
export { SearchBar } from './search-bar'; export { SearchBar } from './search-bar';
export { SelectMulti, type SelectMultiProps } from './select-multi'; export { SelectMulti, type SelectMultiProps } from './select-multi';
export { SelectSingle, type SelectSingleProps } from './select-single'; export { SelectSingle } from './select-single';
export { SelectTree } from './select-tree'; export { SelectTree } from './select-tree';
export { TextArea } from './text-area'; export { TextArea } from './text-area';
export { TextInput } from './text-input'; export { TextInput } from './text-input';

View File

@ -41,10 +41,7 @@ export function SearchBar({
return ( return (
<div className={clsx('relative flex items-center', className)} {...restProps}> <div className={clsx('relative flex items-center', className)} {...restProps}>
{!noIcon ? ( {!noIcon ? (
<IconSearch <IconSearch className='absolute -top-0.5 left-2 translate-y-1/2 cc-search-icon' size='1.25rem' />
className='absolute -top-0.5 left-2 translate-y-1/2 pointer-events-none clr-text-controls'
size='1.25rem'
/>
) : null} ) : null}
<TextInput <TextInput
id={id} id={id}

View File

@ -38,7 +38,7 @@ function ClearIndicator<Option, Group extends GroupBase<Option> = GroupBase<Opti
); );
} }
export interface SelectSingleProps<Option, Group extends GroupBase<Option> = GroupBase<Option>> interface SelectSingleProps<Option, Group extends GroupBase<Option> = GroupBase<Option>>
extends Omit<Props<Option, false, Group>, 'theme' | 'menuPortalTarget'> { extends Omit<Props<Option, false, Group>, 'theme' | 'menuPortalTarget'> {
noPortal?: boolean; noPortal?: boolean;
noBorder?: boolean; noBorder?: boolean;

View File

@ -99,6 +99,7 @@ export function SelectTree<ItemType>({
> >
{foldable.has(item) ? ( {foldable.has(item) ? (
<MiniButton <MiniButton
aria-label={!folded.includes(item) ? 'Свернуть' : 'Развернуть'}
className={clsx('absolute left-1', !folded.includes(item) ? 'top-1.5' : 'top-1')} className={clsx('absolute left-1', !folded.includes(item) ? 'top-1.5' : 'top-1')}
noPadding noPadding
noHover noHover

View File

@ -5,7 +5,7 @@ import { type Editor, type ErrorProcessing, type Titled } from '../props';
import { ErrorField } from './error-field'; import { ErrorField } from './error-field';
import { Label } from './label'; import { Label } from './label';
export interface TextAreaProps extends Editor, ErrorProcessing, Titled, React.ComponentProps<'textarea'> { interface TextAreaProps extends Editor, ErrorProcessing, Titled, React.ComponentProps<'textarea'> {
/** Indicates that the input should be transparent. */ /** Indicates that the input should be transparent. */
transparent?: boolean; transparent?: boolean;
@ -56,7 +56,7 @@ export function TextArea({
fitContent && 'field-sizing-content', fitContent && 'field-sizing-content',
noResize && 'resize-none', noResize && 'resize-none',
transparent ? 'bg-transparent' : 'clr-input', transparent ? 'bg-transparent' : 'clr-input',
!noOutline && 'clr-outline', !noOutline && 'focus-outline',
dense && 'grow max-w-full', dense && 'grow max-w-full',
!dense && !!label && 'mt-2', !dense && !!label && 'mt-2',
!dense && className !dense && className

View File

@ -54,7 +54,7 @@ export function TextInput({
'leading-tight truncate hover:text-clip', 'leading-tight truncate hover:text-clip',
transparent ? 'bg-transparent' : 'clr-input', transparent ? 'bg-transparent' : 'clr-input',
!noBorder && 'border', !noBorder && 'border',
!noOutline && 'clr-outline', !noOutline && 'focus-outline',
(!noBorder || !disabled) && 'px-3', (!noBorder || !disabled) && 'px-3',
dense && 'grow max-w-full', dense && 'grow max-w-full',
!dense && !!label && 'mt-2', !dense && !!label && 'mt-2',

View File

@ -105,9 +105,9 @@ export function ModalForm({
) : null} ) : null}
<MiniButton <MiniButton
noPadding
aria-label='Закрыть'
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')} titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
aria-label='Закрыть'
noPadding
icon={<IconClose size='1.25rem' />} icon={<IconClose size='1.25rem' />}
className='absolute z-pop top-2 right-2' className='absolute z-pop top-2 right-2'
onClick={hideDialog} onClick={hideDialog}

View File

@ -49,9 +49,9 @@ export function ModalView({
) : null} ) : null}
<MiniButton <MiniButton
noPadding
aria-label='Закрыть'
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')} titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
aria-label='Закрыть'
noPadding
icon={<IconClose size='1.25rem' />} icon={<IconClose size='1.25rem' />}
className='absolute z-pop top-2 right-2' className='absolute z-pop top-2 right-2'
onClick={hideDialog} onClick={hideDialog}
@ -71,9 +71,10 @@ export function ModalView({
<div <div
className={clsx( className={clsx(
'@container/modal', '@container/modal',
'max-h-[calc(100svh-8rem)] max-w-[100svw] xs:max-w-[calc(100svw-2rem)]', 'max-w-[100svw] xs:max-w-[calc(100svw-2rem)]',
'overscroll-contain outline-hidden', 'overscroll-contain outline-hidden',
overflowVisible ? 'overflow-visible' : 'overflow-auto', overflowVisible ? 'overflow-visible' : 'overflow-auto',
fullScreen ? 'max-h-[calc(100svh-2rem)]' : 'max-h-[calc(100svh-8rem)]',
className className
)} )}
{...restProps} {...restProps}

View File

@ -14,7 +14,15 @@ interface TabLabelProps extends Omit<TabPropsImpl, 'children'>, Titled {
/** /**
* Displays a tab header with a label. * Displays a tab header with a label.
*/ */
export function TabLabel({ label, title, titleHtml, hideTitle, className, ...otherProps }: TabLabelProps) { export function TabLabel({
label,
title,
titleHtml,
hideTitle,
className,
role = 'tab',
...otherProps
}: TabLabelProps) {
return ( return (
<TabImpl <TabImpl
className={clsx( className={clsx(
@ -31,6 +39,7 @@ export function TabLabel({ label, title, titleHtml, hideTitle, className, ...oth
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
role={role}
{...otherProps} {...otherProps}
> >
{label} {label}

View File

@ -5,5 +5,4 @@ export { PDFViewer } from './pdf-viewer';
export { PrettyJson } from './pretty-json'; export { PrettyJson } from './pretty-json';
export { TextContent } from './text-content'; export { TextContent } from './text-content';
export { ValueIcon } from './value-icon'; export { ValueIcon } from './value-icon';
export { ValueLabeled } from './value-labeled';
export { ValueStats } from './value-stats'; export { ValueStats } from './value-stats';

View File

@ -5,7 +5,7 @@ import { truncateToLastWord } from '@/utils/utils';
import { type Styling } from '../props'; import { type Styling } from '../props';
export interface TextContentProps extends Styling { interface TextContentProps extends Styling {
/** Text to display. */ /** Text to display. */
text: string; text: string;

View File

@ -15,15 +15,9 @@ interface ValueIconProps extends Styling, Titled {
/** Icon to display. */ /** Icon to display. */
icon: React.ReactNode; icon: React.ReactNode;
/** Classname for the text. */
textClassName?: string;
/** Callback to be called when the component is clicked. */ /** Callback to be called when the component is clicked. */
onClick?: (event: React.MouseEvent<Element>) => void; onClick?: (event: React.MouseEvent<Element>) => void;
/** Number of symbols to display in a small size. */
smallThreshold?: number;
/** Indicates that padding should be minimal. */ /** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
@ -39,18 +33,14 @@ export function ValueIcon({
dense, dense,
icon, icon,
value, value,
textClassName,
disabled = true, disabled = true,
title, title,
titleHtml, titleHtml,
hideTitle, hideTitle,
className, className,
smallThreshold,
onClick, onClick,
...restProps ...restProps
}: ValueIconProps) { }: ValueIconProps) {
// TODO: use CSS instead of threshold
const isSmall = !smallThreshold || String(value).length < smallThreshold;
return ( return (
<div <div
className={clsx( className={clsx(
@ -65,11 +55,10 @@ export function ValueIcon({
data-tooltip-html={titleHtml} data-tooltip-html={titleHtml}
data-tooltip-content={title} data-tooltip-content={title}
data-tooltip-hidden={hideTitle} data-tooltip-hidden={hideTitle}
aria-label={title}
> >
<MiniButton noHover noPadding icon={icon} disabled={disabled} onClick={onClick} /> {onClick ? <MiniButton noHover noPadding icon={icon} onClick={onClick} disabled={disabled} /> : icon}
<span id={id} className={clsx({ 'text-xs': !isSmall }, textClassName)}> <span id={id}>{value}</span>
{value}
</span>
</div> </div>
); );
} }

View File

@ -1,29 +0,0 @@
import clsx from 'clsx';
import { type Styling } from '@/components/props';
interface ValueLabeledProps extends Styling {
/** Id of the component. */
id?: string;
/** Label to display. */
label: string;
/** Value to display. */
text: string | number;
/** Tooltip for the component. */
title?: string;
}
/**
* Displays a labeled value.
*/
export function ValueLabeled({ id, label, text, title, className, ...restProps }: ValueLabeledProps) {
return (
<div className={clsx('flex justify-between gap-6', className)} {...restProps}>
<span title={title}>{label}</span>
<span id={id}>{text}</span>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { type Styling, type Titled } from '@/components/props'; import clsx from 'clsx';
import { ValueIcon } from './value-icon'; import { type Styling, type Titled } from '@/components/props';
import { globalIDs } from '@/utils/constants';
// characters - threshold for small labels - small font // characters - threshold for small labels - small font
const SMALL_THRESHOLD = 3; const SMALL_THRESHOLD = 3;
@ -16,9 +17,23 @@ interface ValueStatsProps extends Styling, Titled {
value: string | number; value: string | number;
} }
/** /** Displays statistics value with an icon. */
* Displays statistics value with an icon. export function ValueStats({ id, icon, value, className, title, titleHtml, hideTitle, ...restProps }: ValueStatsProps) {
*/ const isSmall = String(value).length < SMALL_THRESHOLD;
export function ValueStats(props: ValueStatsProps) { return (
return <ValueIcon dense smallThreshold={SMALL_THRESHOLD} textClassName='min-w-5' {...props} />; <div
className={clsx('flex items-center gap-1', 'text-right', 'hover:cursor-default', className)}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}
data-tooltip-html={titleHtml}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle}
aria-label={title}
{...restProps}
>
{icon}
<span id={id} className={clsx(!isSmall && 'text-xs', 'min-w-5')}>
{value}
</span>
</div>
);
} }

View File

@ -64,20 +64,20 @@ export function LoginPage() {
id='username' id='username'
autoComplete='username' autoComplete='username'
label='Логин или email' label='Логин или email'
{...register('username')}
autoFocus autoFocus
allowEnter allowEnter
spellCheck={false} spellCheck={false}
defaultValue={initialName} defaultValue={initialName}
{...register('username')}
error={errors.username} error={errors.username}
/> />
<TextInput <TextInput
id='password' id='password'
{...register('password')}
type='password' type='password'
autoComplete='current-password' autoComplete='current-password'
label='Пароль' label='Пароль'
allowEnter allowEnter
{...register('password')}
error={errors.password} error={errors.password}
/> />

View File

@ -10,13 +10,13 @@ interface SubtopicsProps {
export function Subtopics({ headTopic }: SubtopicsProps) { export function Subtopics({ headTopic }: SubtopicsProps) {
return ( return (
<> <details>
<h2>Содержание раздела</h2> <summary className='text-center font-semibold'>Содержание раздела</summary>
{Object.values(HelpTopic) {Object.values(HelpTopic)
.filter(topic => topic !== headTopic && topicParent.get(topic) === headTopic) .filter(topic => topic !== headTopic && topicParent.get(topic) === headTopic)
.map(topic => ( .map(topic => (
<TopicItem key={`${prefixes.topic_item}${topic}`} topic={topic} /> <TopicItem key={`${prefixes.topic_item}${topic}`} topic={topic} />
))} ))}
</> </details>
); );
} }

View File

@ -26,17 +26,22 @@ export function HelpInterface() {
интерфейса изменяются (цвет, иконка) в зависимости от доступности соответствующего функционала. интерфейса изменяются (цвет, иконка) в зависимости от доступности соответствующего функционала.
</p> </p>
<p> <p>
<IconHelp className='inline-icon' /> <IconHelp className='inline-icon' /> Помимо данного раздела справка предоставляется контекстно через специальную
Помимо данного раздела справка предоставляется контекстно через специальную иконку{' '} иконку <IconHelp className='inline-icon' />
<IconHelp className='inline-icon' />
</p> </p>
<h2>Навигация и настройки</h2> <h2>Навигация и настройки</h2>
<li>Ctrl + клик на объект навигации откроет новую вкладку</li> <li>
<kbd>Ctrl + клик</kbd> на объект навигации откроет новую вкладку
</li>
<li> <li>
<IconPin size='1.25rem' className='inline-icon' /> навигационную панель можно скрыть с помощью кнопки в правом <IconPin size='1.25rem' className='inline-icon' /> навигационную панель можно скрыть с помощью кнопки в правом
верхнем углу верхнем углу
</li> </li>
<li>
<IconLightTheme className='inline-icon' />
<IconDarkTheme className='inline-icon' /> переключатели темы
</li>
<li> <li>
<IconLogin size='1.25rem' className='inline-icon' /> вход в систему / регистрация нового пользователя <IconLogin size='1.25rem' className='inline-icon' /> вход в систему / регистрация нового пользователя
</li> </li>
@ -44,10 +49,7 @@ export function HelpInterface() {
<IconUser2 size='1.25rem' className='inline-icon' /> меню пользователя содержит ряд настроек и переход к профилю <IconUser2 size='1.25rem' className='inline-icon' /> меню пользователя содержит ряд настроек и переход к профилю
пользователя пользователя
</li> </li>
<li>
<IconLightTheme className='inline-icon' />
<IconDarkTheme className='inline-icon' /> переключатели темы
</li>
<li> <li>
<IconHelp className='inline-icon' /> <IconHelp className='inline-icon' />
<IconHelpOff className='inline-icon' /> отключение иконок контекстной справки <IconHelpOff className='inline-icon' /> отключение иконок контекстной справки

View File

@ -20,7 +20,8 @@ export function HelpMain() {
<LinkTopic text='Операционной схеме синтеза' topic={HelpTopic.CC_OSS} />. <LinkTopic text='Операционной схеме синтеза' topic={HelpTopic.CC_OSS} />.
</p> </p>
<h2>Разделы Справки</h2> <details>
<summary className='text-center font-semibold'>Разделы Справки</summary>
{[ {[
HelpTopic.THESAURUS, HelpTopic.THESAURUS,
HelpTopic.INTERFACE, HelpTopic.INTERFACE,
@ -34,6 +35,7 @@ export function HelpMain() {
].map(topic => ( ].map(topic => (
<TopicItem key={`${prefixes.topic_item}${topic}`} topic={topic} /> <TopicItem key={`${prefixes.topic_item}${topic}`} topic={topic} />
))} ))}
</details>
<h2>Лицензирование и раскрытие информации</h2> <h2>Лицензирование и раскрытие информации</h2>
<li>Пользователи Портала сохраняют авторские права на создаваемый ими контент</li> <li>Пользователи Портала сохраняют авторские права на создаваемый ими контент</li>

View File

@ -259,6 +259,10 @@ export function HelpThesaurus() {
<h2>Операция</h2> <h2>Операция</h2>
<p>Операция выделенная часть ОСС, определяющая способ получения КС в рамках ОСС.</p> <p>Операция выделенная часть ОСС, определяющая способ получения КС в рамках ОСС.</p>
<p>
<IconConsolidation className='inline-icon' />
{'\u2009'}Ромбовидный синтез операция, где используются КС, имеющие общих предков.
</p>
<ul> <ul>
По <b>способу получения КС выделены</b>: По <b>способу получения КС выделены</b>:
@ -271,13 +275,6 @@ export function HelpThesaurus() {
{'\u2009'}синтез концептуальных схем. {'\u2009'}синтез концептуальных схем.
</li> </li>
</ul> </ul>
<br />
<p>
<IconConsolidation className='inline-icon' />
{'\u2009'}Ромбовидный синтез операция, где используются КС, имеющие общих предков.
</p>
</div> </div>
); );
} }

View File

@ -34,8 +34,12 @@ export function HelpLibrary() {
<li> <li>
<span className='text-(--acc-fg-green)'>зеленым текстом</span> выделены ОСС <span className='text-(--acc-fg-green)'>зеленым текстом</span> выделены ОСС
</li> </li>
<li>клик по строке - переход к редактированию схемы</li> <li>
<li>Ctrl + клик по строке откроет схему в новой вкладке</li> <kbd>клик</kbd> по строке - переход к редактированию схемы
</li>
<li>
<kbd>Ctrl + клик</kbd> по строке откроет схему в новой вкладке
</li>
<li>Фильтры атрибутов три позиции: да/нет/не применять</li> <li>Фильтры атрибутов три позиции: да/нет/не применять</li>
<li> <li>
<IconShow size='1rem' className='inline-icon' /> фильтры атрибутов применяются по клику <IconShow size='1rem' className='inline-icon' /> фильтры атрибутов применяются по клику
@ -67,9 +71,15 @@ export function HelpLibrary() {
<li> <li>
<IconSubfolders size='1rem' className='inline-icon icon-green' /> схемы во вложенных папках <IconSubfolders size='1rem' className='inline-icon icon-green' /> схемы во вложенных папках
</li> </li>
<li>клик по папке отображает справа схемы в ней</li> <li>
<li>Ctrl + клик по папке копирует путь в буфер обмена</li> <kbd>клик</kbd> по папке отображает справа схемы в ней
<li>клик по иконке сворачивает/разворачивает вложенные</li> </li>
<li>
<kbd>Ctrl + клик по папке копирует путь в буфер обмена</kbd>
</li>
<li>
<kbd>клик</kbd> по иконке сворачивает/разворачивает вложенные
</li>
<li> <li>
<IconFolderEmpty size='1rem' className='inline-icon clr-text-default' /> папка без схем <IconFolderEmpty size='1rem' className='inline-icon clr-text-default' /> папка без схем
</li> </li>

View File

@ -53,10 +53,14 @@ export function HelpOssGraph() {
<div className='sm:w-84'> <div className='sm:w-84'>
<h1>Изменение узлов</h1> <h1>Изменение узлов</h1>
<li>Клик на операцию выделение</li>
<li>Esc сбросить выделение</li>
<li> <li>
Двойной клик переход к связанной <LinkTopic text='КС' topic={HelpTopic.CC_SYSTEM} /> <kbd>Клик</kbd> на операцию выделение
</li>
<li>
<kbd>Esc</kbd> сбросить выделение
</li>
<li>
<kbd>Двойной клик</kbd> переход к связанной <LinkTopic text='КС' topic={HelpTopic.CC_SYSTEM} />
</li> </li>
<li> <li>
<IconEdit2 className='inline-icon' /> Редактирование операции <IconEdit2 className='inline-icon' /> Редактирование операции
@ -65,7 +69,7 @@ export function HelpOssGraph() {
<IconNewItem className='inline-icon icon-green' /> Новая операция <IconNewItem className='inline-icon icon-green' /> Новая операция
</li> </li>
<li> <li>
<IconDestroy className='inline-icon icon-red' /> Delete удалить выбранные <IconDestroy className='inline-icon icon-red' /> <kbd>Delete</kbd> удалить выбранные
</li> </li>
</div> </div>
</div> </div>

View File

@ -34,7 +34,7 @@ export function HelpRSCard() {
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} /> <IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
</li> </li>
<li> <li>
<IconSave className='inline-icon' /> сохранить изменения: Ctrl + S <IconSave className='inline-icon' /> сохранить изменения: <kbd>Ctrl + S</kbd>
</li> </li>
<li> <li>
<IconEditor className='inline-icon' /> Редактор обладает правом редактирования <IconEditor className='inline-icon' /> Редактор обладает правом редактирования

View File

@ -38,13 +38,13 @@ export function HelpRSEditor() {
<IconList className='inline-icon' /> список конституент <IconList className='inline-icon' /> список конституент
</li> </li>
<li> <li>
<IconSave className='inline-icon' /> сохранить: Ctrl + S <IconSave className='inline-icon' /> сохранить: <kbd>Ctrl + S</kbd>
</li> </li>
<li> <li>
<IconReset className='inline-icon' /> сбросить изменения <IconReset className='inline-icon' /> сбросить изменения
</li> </li>
<li> <li>
<IconClone className='inline-icon icon-green' /> клонировать: Alt + V <IconClone className='inline-icon icon-green' /> клонировать: <kbd>Alt + V</kbd>
</li> </li>
<li> <li>
<IconNewItem className='inline-icon icon-green' /> новая конституента <IconNewItem className='inline-icon icon-green' /> новая конституента
@ -58,7 +58,7 @@ export function HelpRSEditor() {
<h2>Список конституент</h2> <h2>Список конституент</h2>
<li> <li>
<IconMoveDown className='inline-icon' /> <IconMoveDown className='inline-icon' />
<IconMoveUp className='inline-icon' /> Alt + вверх/вниз <IconMoveUp className='inline-icon' /> <kbd>Alt + вверх/вниз</kbd>
</li> </li>
<li> <li>
<IconFilter className='inline-icon' /> <IconFilter className='inline-icon' />
@ -98,14 +98,18 @@ export function HelpRSEditor() {
<IconTree className='inline-icon' /> отображение{' '} <IconTree className='inline-icon' /> отображение{' '}
<LinkTopic text='дерева разбора' topic={HelpTopic.UI_FORMULA_TREE} /> <LinkTopic text='дерева разбора' topic={HelpTopic.UI_FORMULA_TREE} />
</li> </li>
<li>Ctrl + Пробел вставка незанятого имени / замена проекции</li> <li>
<kbd>Ctrl + Пробел</kbd> вставка незанятого имени / замена проекции
</li>
<h2>Термин и Текстовое определение</h2> <h2>Термин и Текстовое определение</h2>
<li> <li>
<IconEdit className='inline-icon' /> редактирование <LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} />{' '} <IconEdit className='inline-icon' /> редактирование <LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} />{' '}
/ <LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} /> / <LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
</li> </li>
<li>Ctrl + Пробел открывает редактирование отсылок</li> <li>
<kbd>Ctrl + Пробел</kbd> открывает редактирование отсылок
</li>
</div> </div>
); );
} }

View File

@ -33,28 +33,34 @@ export function HelpRSList() {
<IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} /> <IconOSS className='inline-icon' /> переход к связанной <LinkTopic text='ОСС' topic={HelpTopic.CC_OSS} />
</li> </li>
<li> <li>
<IconReset className='inline-icon' /> сбросить выделение: ESC <IconReset className='inline-icon' /> сбросить выделение: <kbd>ESC</kbd>
</li> </li>
<li>Клик на строку выделение</li> <li>Клик на строку выделение</li>
<li>Shift + клик выделение нескольких</li> <li>
<li>Alt + клик Редактор</li> <kbd>Shift + клик</kbd> выделение нескольких
<li>Двойной клик Редактор</li> </li>
<li>
<kbd>Alt + клик</kbd> Редактор
</li>
<li>
<kbd>Двойной клик</kbd> Редактор
</li>
<li> <li>
<IconMoveUp className='inline-icon' /> <IconMoveUp className='inline-icon' />
<IconMoveDown className='inline-icon' /> Alt + вверх/вниз перемещение <IconMoveDown className='inline-icon' /> <kbd>Alt + вверх/вниз</kbd> перемещение
</li> </li>
<li> <li>
<IconClone className='inline-icon icon-green' /> клонировать выделенную: Alt + V <IconClone className='inline-icon icon-green' /> клонировать выделенную: <kbd>Alt + V</kbd>
</li> </li>
<li> <li>
<IconNewItem className='inline-icon icon-green' /> новая конституента: Alt + ` <IconNewItem className='inline-icon icon-green' /> новая конституента: <kbd>Alt + `</kbd>
</li> </li>
<li> <li>
<IconOpenList className='inline-icon icon-green' /> быстрое добавление: Alt + 1-6,Q,W <IconOpenList className='inline-icon icon-green' /> быстрое добавление: <kbd>Alt + 1-6,Q,W</kbd>
</li> </li>
<li> <li>
<IconDestroy className='inline-icon icon-red' /> удаление выделенных: Delete <IconDestroy className='inline-icon icon-red' /> удаление выделенных: <kbd>Delete</kbd>
</li> </li>
<Divider margins='my-2' /> <Divider margins='my-2' />

View File

@ -1,53 +1,55 @@
/** /**
* Represents manuals topic. * Represents manuals topic.
*/ */
export enum HelpTopic { export const HelpTopic = {
MAIN = 'main', MAIN: 'main',
THESAURUS = 'thesaurus', THESAURUS: 'thesaurus',
INTERFACE = 'user-interface', INTERFACE: 'user-interface',
UI_LIBRARY = 'ui-library', UI_LIBRARY: 'ui-library',
UI_RS_MENU = 'ui-rsform-menu', UI_RS_MENU: 'ui-rsform-menu',
UI_RS_CARD = 'ui-rsform-card', UI_RS_CARD: 'ui-rsform-card',
UI_RS_LIST = 'ui-rsform-list', UI_RS_LIST: 'ui-rsform-list',
UI_RS_EDITOR = 'ui-rsform-editor', UI_RS_EDITOR: 'ui-rsform-editor',
UI_GRAPH_TERM = 'ui-graph-term', UI_GRAPH_TERM: 'ui-graph-term',
UI_FORMULA_TREE = 'ui-formula-tree', UI_FORMULA_TREE: 'ui-formula-tree',
UI_TYPE_GRAPH = 'ui-type-graph', UI_TYPE_GRAPH: 'ui-type-graph',
UI_CST_STATUS = 'ui-rsform-cst-status', UI_CST_STATUS: 'ui-rsform-cst-status',
UI_CST_CLASS = 'ui-rsform-cst-class', UI_CST_CLASS: 'ui-rsform-cst-class',
UI_OSS_GRAPH = 'ui-oss-graph', UI_OSS_GRAPH: 'ui-oss-graph',
UI_SUBSTITUTIONS = 'ui-substitutions', UI_SUBSTITUTIONS: 'ui-substitutions',
UI_RELOCATE_CST = 'ui-relocate-cst', UI_RELOCATE_CST: 'ui-relocate-cst',
CONCEPTUAL = 'concept', CONCEPTUAL: 'concept',
CC_SYSTEM = 'concept-rsform', CC_SYSTEM: 'concept-rsform',
CC_CONSTITUENTA = 'concept-constituenta', CC_CONSTITUENTA: 'concept-constituenta',
CC_RELATIONS = 'concept-relations', CC_RELATIONS: 'concept-relations',
CC_SYNTHESIS = 'concept-synthesis', CC_SYNTHESIS: 'concept-synthesis',
CC_OSS = 'concept-operations-schema', CC_OSS: 'concept-operations-schema',
CC_PROPAGATION = 'concept-change-propagation', CC_PROPAGATION: 'concept-change-propagation',
RSLANG = 'rslang', RSLANG: 'rslang',
RSL_TYPES = 'rslang-types', RSL_TYPES: 'rslang-types',
RSL_CORRECT = 'rslang-correctness', RSL_CORRECT: 'rslang-correctness',
RSL_INTERPRET = 'rslang-interpretation', RSL_INTERPRET: 'rslang-interpretation',
RSL_OPERATIONS = 'rslang-operations', RSL_OPERATIONS: 'rslang-operations',
RSL_TEMPLATES = 'rslang-templates', RSL_TEMPLATES: 'rslang-templates',
TERM_CONTROL = 'terminology-control', TERM_CONTROL: 'terminology-control',
ACCESS = 'access', ACCESS: 'access',
VERSIONS = 'versions', VERSIONS: 'versions',
INFO = 'documentation', INFO: 'documentation',
INFO_RULES = 'rules', INFO_RULES: 'rules',
INFO_CONTRIB = 'contributors', INFO_CONTRIB: 'contributors',
INFO_PRIVACY = 'privacy', INFO_PRIVACY: 'privacy',
INFO_API = 'api', INFO_API: 'api',
EXTEOR = 'exteor' EXTEOR: 'exteor'
} } as const;
export type HelpTopic = (typeof HelpTopic)[keyof typeof HelpTopic];
/** /**
* Manual topics hierarchy. * Manual topics hierarchy.
@ -99,8 +101,3 @@ export const topicParent = new Map<HelpTopic, HelpTopic>([
[HelpTopic.EXTEOR, HelpTopic.EXTEOR] [HelpTopic.EXTEOR, HelpTopic.EXTEOR]
]); ]);
/**
* Topics that can be folded.
*/
export const foldableTopics = [HelpTopic.INTERFACE, HelpTopic.RSLANG, HelpTopic.CONCEPTUAL, HelpTopic.INFO];

View File

@ -30,6 +30,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
return ( return (
<div <div
ref={menu.ref} ref={menu.ref}
onBlur={menu.handleBlur}
className={clsx( className={clsx(
'absolute left-0 w-54', // 'absolute left-0 w-54', //
noNavigation ? 'top-0' : 'top-12', noNavigation ? 'top-0' : 'top-12',

View File

@ -1,14 +1,17 @@
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { PARAMETER } from '@/utils/constants';
export function HomePage() { export function HomePage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
if (isAnonymous) { if (isAnonymous) {
router.replace({ path: urls.manuals }); // Note: Timeout is needed to let router initialize
setTimeout(() => router.replace({ path: urls.login }), PARAMETER.minimalTimeout);
} else { } else {
router.replace({ path: urls.library }); setTimeout(() => router.replace({ path: urls.library }), PARAMETER.minimalTimeout);
} }
return null; return null;

View File

@ -5,17 +5,19 @@ import { errorMsg } from '@/utils/labels';
import { validateLocation } from '../models/library-api'; import { validateLocation } from '../models/library-api';
/** Represents type of library items. */ /** Represents type of library items. */
export enum LibraryItemType { export const LibraryItemType = {
RSFORM = 'rsform', RSFORM: 'rsform',
OSS = 'oss' OSS: 'oss'
} } as const;
export type LibraryItemType = (typeof LibraryItemType)[keyof typeof LibraryItemType];
/** Represents Access policy for library items.*/ /** Represents Access policy for library items.*/
export enum AccessPolicy { export const AccessPolicy = {
PUBLIC = 'public', PUBLIC: 'public',
PROTECTED = 'protected', PROTECTED: 'protected',
PRIVATE = 'private' PRIVATE: 'private'
} } as const;
export type AccessPolicy = (typeof AccessPolicy)[keyof typeof AccessPolicy];
/** Represents library item common data typical for all item types. */ /** Represents library item common data typical for all item types. */
export type ILibraryItem = z.infer<typeof schemaLibraryItem>; export type ILibraryItem = z.infer<typeof schemaLibraryItem>;
@ -53,17 +55,19 @@ export type IVersionCreateDTO = z.infer<typeof schemaVersionCreate>;
export type IVersionUpdateDTO = z.infer<typeof schemaVersionUpdate>; export type IVersionUpdateDTO = z.infer<typeof schemaVersionUpdate>;
// ======= SCHEMAS ========= // ======= SCHEMAS =========
export const schemaLibraryItemType = z.enum(Object.values(LibraryItemType) as [LibraryItemType, ...LibraryItemType[]]);
export const schemaAccessPolicy = z.enum(Object.values(AccessPolicy) as [AccessPolicy, ...AccessPolicy[]]);
export const schemaLibraryItem = z.strictObject({ export const schemaLibraryItem = z.strictObject({
id: z.coerce.number(), id: z.coerce.number(),
item_type: z.nativeEnum(LibraryItemType), item_type: schemaLibraryItemType,
title: z.string(), title: z.string(),
alias: z.string().nonempty(), alias: z.string().nonempty(),
comment: z.string(), description: z.string(),
visible: z.boolean(), visible: z.boolean(),
read_only: z.boolean(), read_only: z.boolean(),
location: z.string(), location: z.string(),
access_policy: z.nativeEnum(AccessPolicy), access_policy: schemaAccessPolicy,
time_create: z.string().datetime({ offset: true }), time_create: z.string().datetime({ offset: true }),
time_update: z.string().datetime({ offset: true }), time_update: z.string().datetime({ offset: true }),
@ -78,7 +82,7 @@ export const schemaCloneLibraryItem = schemaLibraryItem
item_type: true, item_type: true,
title: true, title: true,
alias: true, alias: true,
comment: true, description: true,
visible: true, visible: true,
read_only: true, read_only: true,
location: true, location: true,
@ -94,14 +98,14 @@ export const schemaCloneLibraryItem = schemaLibraryItem
export const schemaCreateLibraryItem = z export const schemaCreateLibraryItem = z
.object({ .object({
item_type: z.nativeEnum(LibraryItemType), item_type: schemaLibraryItemType,
title: z.string().optional(), title: z.string().optional(),
alias: z.string().optional(), alias: z.string().optional(),
comment: z.string(), description: z.string(),
visible: z.boolean(), visible: z.boolean(),
read_only: z.boolean(), read_only: z.boolean(),
location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation }), location: z.string().refine(data => validateLocation(data), { message: errorMsg.invalidLocation }),
access_policy: z.nativeEnum(AccessPolicy), access_policy: schemaAccessPolicy,
file: z.instanceof(File).optional(), file: z.instanceof(File).optional(),
fileName: z.string().optional() fileName: z.string().optional()
@ -117,10 +121,10 @@ export const schemaCreateLibraryItem = z
export const schemaUpdateLibraryItem = z.strictObject({ export const schemaUpdateLibraryItem = z.strictObject({
id: z.number(), id: z.number(),
item_type: z.nativeEnum(LibraryItemType), item_type: schemaLibraryItemType,
title: z.string().nonempty(errorMsg.requiredField), title: z.string().nonempty(errorMsg.requiredField),
alias: z.string().nonempty(errorMsg.requiredField), alias: z.string().nonempty(errorMsg.requiredField),
comment: z.string(), description: z.string(),
visible: z.boolean(), visible: z.boolean(),
read_only: z.boolean() read_only: z.boolean()
}); });

View File

@ -85,9 +85,9 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
<div className='flex flex-col'> <div className='flex flex-col'>
<div className='relative flex justify-stretch sm:mb-1 max-w-120 gap-3'> <div className='relative flex justify-stretch sm:mb-1 max-w-120 gap-3'>
<MiniButton <MiniButton
title='Открыть в библиотеке'
noHover noHover
noPadding noPadding
title='Открыть в библиотеке'
icon={<IconFolderOpened size='1.25rem' className='icon-primary' />} icon={<IconFolderOpened size='1.25rem' className='icon-primary' />}
onClick={handleOpenLibrary} onClick={handleOpenLibrary}
/> />
@ -101,7 +101,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
/> />
</div> </div>
<div className='relative'> <div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}>
{ownerSelector.isOpen ? ( {ownerSelector.isOpen ? (
<div className='absolute -top-2 right-0'> <div className='absolute -top-2 right-0'>
<SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} /> <SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} />
@ -133,23 +133,21 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
</Tooltip> </Tooltip>
<ValueIcon <ValueIcon
title='Дата обновления'
dense dense
disabled
icon={<IconDateUpdate size='1.25rem' className='text-ok-600' />} icon={<IconDateUpdate size='1.25rem' className='text-ok-600' />}
value={new Date(schema.time_update).toLocaleString(intl.locale)} value={new Date(schema.time_update).toLocaleString(intl.locale)}
title='Дата обновления'
/> />
<ValueIcon <ValueIcon
title='Дата создания'
dense dense
disabled
icon={<IconDateCreate size='1.25rem' className='text-ok-600' />} icon={<IconDateCreate size='1.25rem' className='text-ok-600' />}
value={new Date(schema.time_create).toLocaleString(intl.locale, { value={new Date(schema.time_create).toLocaleString(intl.locale, {
year: '2-digit', year: '2-digit',
month: '2-digit', month: '2-digit',
day: '2-digit' day: '2-digit'
})} })}
title='Дата создания'
/> />
</div> </div>
</div> </div>

View File

@ -44,7 +44,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
} }
return ( return (
<div ref={accessMenu.ref} className='relative'> <div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'>
<Button <Button
dense dense
noBorder noBorder
@ -67,22 +67,22 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
text={labelUserRole(UserRole.EDITOR)} text={labelUserRole(UserRole.EDITOR)}
title={describeUserRole(UserRole.EDITOR)} title={describeUserRole(UserRole.EDITOR)}
icon={<IconRole role={UserRole.EDITOR} size='1rem' />} icon={<IconRole role={UserRole.EDITOR} size='1rem' />}
disabled={!isOwned && !isEditor}
onClick={() => handleChangeMode(UserRole.EDITOR)} onClick={() => handleChangeMode(UserRole.EDITOR)}
disabled={!isOwned && !isEditor}
/> />
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.OWNER)} text={labelUserRole(UserRole.OWNER)}
title={describeUserRole(UserRole.OWNER)} title={describeUserRole(UserRole.OWNER)}
icon={<IconRole role={UserRole.OWNER} size='1rem' />} icon={<IconRole role={UserRole.OWNER} size='1rem' />}
disabled={!isOwned}
onClick={() => handleChangeMode(UserRole.OWNER)} onClick={() => handleChangeMode(UserRole.OWNER)}
disabled={!isOwned}
/> />
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.ADMIN)} text={labelUserRole(UserRole.ADMIN)}
title={describeUserRole(UserRole.ADMIN)} title={describeUserRole(UserRole.ADMIN)}
icon={<IconRole role={UserRole.ADMIN} size='1rem' />} icon={<IconRole role={UserRole.ADMIN} size='1rem' />}
disabled={!user.is_staff}
onClick={() => handleChangeMode(UserRole.ADMIN)} onClick={() => handleChangeMode(UserRole.ADMIN)}
disabled={!user.is_staff}
/> />
</Dropdown> </Dropdown>
</div> </div>

View File

@ -28,10 +28,15 @@ export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: Mi
} }
return ( return (
<div ref={ossMenu.ref} className={clsx('relative flex items-center', className)} {...restProps}> <div
ref={ossMenu.ref}
onBlur={ossMenu.handleBlur}
className={clsx('relative flex items-center', className)}
{...restProps}
>
<MiniButton <MiniButton
icon={<IconOSS size='1.25rem' className='icon-primary' />}
title='Операционные схемы' title='Операционные схемы'
icon={<IconOSS size='1.25rem' className='icon-primary' />}
hideTitle={ossMenu.isOpen} hideTitle={ossMenu.isOpen}
onClick={onToggle} onClick={onToggle}
/> />
@ -40,9 +45,9 @@ export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: Mi
<Label text='Список ОСС' className='border-b px-3 py-1' /> <Label text='Список ОСС' className='border-b px-3 py-1' />
{items.map((reference, index) => ( {items.map((reference, index) => (
<DropdownButton <DropdownButton
className='min-w-20'
key={`${prefixes.oss_list}${index}`} key={`${prefixes.oss_list}${index}`}
text={reference.alias} text={reference.alias}
className='min-w-20'
onClick={event => onSelect(event, reference)} onClick={event => onSelect(event, reference)}
/> />
))} ))}

View File

@ -35,7 +35,12 @@ export function SelectLocationContext({
} }
return ( return (
<div ref={menu.ref} className={clsx('relative text-right self-start', className)} {...restProps}> <div
ref={menu.ref} //
onBlur={menu.handleBlur}
className={clsx('relative text-right self-start', className)}
{...restProps}
>
<MiniButton <MiniButton
title={title} title={title}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}

View File

@ -112,10 +112,10 @@ export function PickSchema({
query={filterText} query={filterText}
onChangeQuery={newValue => setFilterText(newValue)} onChangeQuery={newValue => setFilterText(newValue)}
/> />
<div className='relative' ref={locationMenu.ref}> <div className='relative' ref={locationMenu.ref} onBlur={locationMenu.handleBlur}>
<MiniButton <MiniButton
icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />}
title='Фильтр по расположению' title='Фильтр по расположению'
icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />}
className='mt-1' className='mt-1'
onClick={() => locationMenu.toggle()} onClick={() => locationMenu.toggle()}
/> />

View File

@ -38,7 +38,7 @@ export function SelectAccessPolicy({
} }
return ( return (
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}> <div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
<MiniButton <MiniButton
title={`Доступ: ${labelAccessPolicy(value)}`} title={`Доступ: ${labelAccessPolicy(value)}`}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}

View File

@ -37,7 +37,7 @@ export function SelectItemType({
} }
return ( return (
<div ref={menu.ref} className={clsx('relative', className)} {...restProps}> <div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
transparent transparent
title={describeLibraryItemType(value)} title={describeLibraryItemType(value)}

View File

@ -33,7 +33,12 @@ export function SelectLocationHead({
} }
return ( return (
<div ref={menu.ref} className={clsx('text-right relative', className)} {...restProps}> <div
ref={menu.ref} //
onBlur={menu.handleBlur}
className={clsx('text-right relative', className)}
{...restProps}
>
<SelectorButton <SelectorButton
transparent transparent
tabIndex={-1} tabIndex={-1}
@ -52,10 +57,10 @@ export function SelectLocationHead({
return ( return (
<DropdownButton <DropdownButton
key={`${prefixes.location_head_list}${index}`} key={`${prefixes.location_head_list}${index}`}
onClick={() => handleChange(head)}
title={describeLocationHead(head)}
icon={<IconLocationHead value={head} size='1rem' />}
text={labelLocationHead(head)} text={labelLocationHead(head)}
title={describeLocationHead(head)}
onClick={() => handleChange(head)}
icon={<IconLocationHead value={head} size='1rem' />}
/> />
); );
})} })}

View File

@ -84,6 +84,7 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
<IconFolderOpened size='1rem' className='icon-green' /> <IconFolderOpened size='1rem' className='icon-green' />
) )
} }
aria-label='Отображение вложенных папок'
onClick={event => handleClickFold(event, item, folded.includes(item))} onClick={event => handleClickFold(event, item, folded.includes(item))}
/> />
) : ( ) : (

View File

@ -48,13 +48,14 @@ export function ToolbarItemAccess({
<Label text='Доступ' className='self-center select-none' /> <Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'> <div className='ml-auto cc-icons'>
<SelectAccessPolicy <SelectAccessPolicy
disabled={role <= UserRole.EDITOR || isProcessing || isAttachedToOSS}
value={policy} value={policy}
onChange={handleSetAccessPolicy} onChange={handleSetAccessPolicy}
disabled={role <= UserRole.EDITOR || isProcessing || isAttachedToOSS}
/> />
<MiniButton <MiniButton
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'} title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
aria-label='Переключатель отображения библиотеки'
icon={<IconItemVisibility value={visible} />} icon={<IconItemVisibility value={visible} />}
onClick={toggleVisible} onClick={toggleVisible}
disabled={role === UserRole.READER || isProcessing} disabled={role === UserRole.READER || isProcessing}
@ -62,6 +63,7 @@ export function ToolbarItemAccess({
<MiniButton <MiniButton
title={readOnly ? 'Изменение: запрещено' : 'Изменение: разрешено'} title={readOnly ? 'Изменение: запрещено' : 'Изменение: разрешено'}
aria-label='Переключатель режима изменения'
icon={ icon={
readOnly ? ( readOnly ? (
<IconImmutable size='1.25rem' className='text-sec-600' /> <IconImmutable size='1.25rem' className='text-sec-600' />

View File

@ -56,13 +56,15 @@ export function ToolbarItemCard({ className, schema, onSubmit, isMutable, delete
{isMutable || isModified ? ( {isMutable || isModified ? (
<MiniButton <MiniButton
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')} titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}
disabled={!canSave} aria-label='Сохранить изменения'
icon={<IconSave size='1.25rem' className='icon-primary' />} icon={<IconSave size='1.25rem' className='icon-primary' />}
onClick={onSubmit} onClick={onSubmit}
disabled={!canSave}
/> />
) : null} ) : null}
<MiniButton <MiniButton
titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)} titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)}
aria-label='Поделиться схемой'
icon={<IconShare size='1.25rem' className='icon-primary' />} icon={<IconShare size='1.25rem' className='icon-primary' />}
onClick={sharePage} onClick={sharePage}
disabled={schema.access_policy !== AccessPolicy.PUBLIC} disabled={schema.access_policy !== AccessPolicy.PUBLIC}
@ -71,8 +73,8 @@ export function ToolbarItemCard({ className, schema, onSubmit, isMutable, delete
<MiniButton <MiniButton
title='Удалить схему' title='Удалить схему'
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={!isMutable || isProcessing || role < UserRole.OWNER}
onClick={deleteSchema} onClick={deleteSchema}
disabled={!isMutable || isProcessing || role < UserRole.OWNER}
/> />
) : null} ) : null}
<BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} /> <BadgeHelp topic={HelpTopic.UI_RS_CARD} offset={4} />

View File

@ -43,7 +43,7 @@ export function DlgCloneLibraryItem() {
item_type: base.item_type, item_type: base.item_type,
title: cloneTitle(base), title: cloneTitle(base),
alias: base.alias, alias: base.alias,
comment: base.comment, description: base.description,
visible: true, visible: true,
read_only: false, read_only: false,
access_policy: AccessPolicy.PUBLIC, access_policy: AccessPolicy.PUBLIC,
@ -68,7 +68,7 @@ export function DlgCloneLibraryItem() {
> >
<TextInput <TextInput
id='dlg_full_name' // id='dlg_full_name' //
label='Полное название' label='Название'
{...register('title')} {...register('title')}
error={errors.title} error={errors.title}
/> />
@ -95,6 +95,7 @@ export function DlgCloneLibraryItem() {
render={({ field }) => ( render={({ field }) => (
<MiniButton <MiniButton
title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'} title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
aria-label='Переключатель отображения библиотеки'
icon={<IconItemVisibility value={field.value} />} icon={<IconItemVisibility value={field.value} />}
onClick={() => field.onChange(!field.value)} onClick={() => field.onChange(!field.value)}
/> />
@ -108,11 +109,17 @@ export function DlgCloneLibraryItem() {
control={control} control={control}
name='location' name='location'
render={({ field }) => ( render={({ field }) => (
<PickLocation value={field.value} rows={2} onChange={field.onChange} error={errors.location} /> <PickLocation
value={field.value} //
rows={2}
onChange={field.onChange}
className={!!errors.location ? '-mb-6' : undefined}
error={errors.location}
/>
)} )}
/> />
<TextArea id='dlg_comment' {...register('comment')} label='Описание' rows={4} error={errors.comment} /> <TextArea id='dlg_comment' {...register('description')} label='Описание' rows={4} error={errors.description} />
{selected.length > 0 ? ( {selected.length > 0 ? (
<Controller <Controller

View File

@ -47,12 +47,12 @@ export function DlgEditEditors() {
<div className='self-center text-sm font-semibold'> <div className='self-center text-sm font-semibold'>
<span>Всего редакторов [{selected.length}]</span> <span>Всего редакторов [{selected.length}]</span>
<MiniButton <MiniButton
noHover
title='Очистить список' title='Очистить список'
noHover
className='py-0 align-middle' className='py-0 align-middle'
icon={<IconRemove size='1.5rem' className='icon-red' />} icon={<IconRemove size='1.5rem' className='icon-red' />}
disabled={selected.length === 0}
onClick={() => setSelected([])} onClick={() => setSelected([])}
disabled={selected.length === 0}
/> />
</div> </div>

View File

@ -107,14 +107,15 @@ export function DlgEditVersions() {
<MiniButton <MiniButton
type='submit' type='submit'
title={isValid ? 'Сохранить изменения' : errorMsg.versionTaken} title={isValid ? 'Сохранить изменения' : errorMsg.versionTaken}
disabled={!isDirty || !isValid || isProcessing} aria-label='Сохранить изменения'
icon={<IconSave size='1.25rem' className='icon-primary' />} icon={<IconSave size='1.25rem' className='icon-primary' />}
disabled={!isDirty || !isValid || isProcessing}
/> />
<MiniButton <MiniButton
title='Сбросить несохраненные изменения' title='Сбросить несохраненные изменения'
disabled={!isDirty}
onClick={() => reset()} onClick={() => reset()}
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
disabled={!isDirty}
/> />
</div> </div>
</form> </form>

View File

@ -66,9 +66,9 @@ export function TableVersions({ processing, items, onDelete, selected, onSelect
className='align-middle' className='align-middle'
noHover noHover
noPadding noPadding
disabled={processing}
icon={<IconRemove size='1.25rem' className='icon-red' />} icon={<IconRemove size='1.25rem' className='icon-red' />}
onClick={event => handleDeleteVersion(event, props.row.original.id)} onClick={event => handleDeleteVersion(event, props.row.original.id)}
disabled={processing}
/> />
) )
}) })

View File

@ -40,13 +40,6 @@ export function labelFolderNode(node: FolderNode): string {
} }
} }
/**
* Retrieves description for {@link FolderNode}.
*/
export function describeFolderNode(node: FolderNode): string {
return `${node.filesInside} | ${node.filesTotal}`;
}
/** /**
* Retrieves label for {@link AccessPolicy}. * Retrieves label for {@link AccessPolicy}.
*/ */

View File

@ -9,7 +9,7 @@ describe('Testing matching LibraryItem', () => {
item_type: LibraryItemType.RSFORM, item_type: LibraryItemType.RSFORM,
title: 'Item1', title: 'Item1',
alias: 'I1', alias: 'I1',
comment: 'comment', description: 'description',
time_create: 'I2', time_create: 'I2',
time_update: '', time_update: '',
owner: null, owner: null,
@ -24,7 +24,7 @@ describe('Testing matching LibraryItem', () => {
item_type: LibraryItemType.RSFORM, item_type: LibraryItemType.RSFORM,
title: '', title: '',
alias: '', alias: '',
comment: '', description: '',
time_create: '', time_create: '',
time_update: '', time_update: '',
owner: null, owner: null,
@ -46,7 +46,7 @@ describe('Testing matching LibraryItem', () => {
expect(matchLibraryItem(item1, item1.title + '@invalid')).toEqual(false); expect(matchLibraryItem(item1, item1.title + '@invalid')).toEqual(false);
expect(matchLibraryItem(item1, item1.alias + '@invalid')).toEqual(false); expect(matchLibraryItem(item1, item1.alias + '@invalid')).toEqual(false);
expect(matchLibraryItem(item1, item1.time_create)).toEqual(false); expect(matchLibraryItem(item1, item1.time_create)).toEqual(false);
expect(matchLibraryItem(item1, item1.comment)).toEqual(true); expect(matchLibraryItem(item1, item1.description)).toEqual(true);
}); });
}); });

View File

@ -17,7 +17,7 @@ const LOCATION_REGEXP = /^\/[PLUS]((\/[!\d\p{L}]([!\d\p{L}\- ]*[!\d\p{L}])?)*)?$
*/ */
export function matchLibraryItem(target: ILibraryItem, query: string): boolean { export function matchLibraryItem(target: ILibraryItem, query: string): boolean {
const matcher = new TextMatcher(query); const matcher = new TextMatcher(query);
return matcher.test(target.alias) || matcher.test(target.title) || matcher.test(target.comment); return matcher.test(target.alias) || matcher.test(target.title) || matcher.test(target.description);
} }
/** /**

View File

@ -5,12 +5,13 @@
/** /**
* Represents valid location headers. * Represents valid location headers.
*/ */
export enum LocationHead { export const LocationHead = {
USER = '/U', USER: '/U',
COMMON = '/S', COMMON: '/S',
PROJECTS = '/P', PROJECTS: '/P',
LIBRARY = '/L' LIBRARY: '/L'
} } as const;
export type LocationHead = (typeof LocationHead)[keyof typeof LocationHead];
export const BASIC_SCHEMAS = '/L/Базовые'; export const BASIC_SCHEMAS = '/L/Базовые';

Some files were not shown because too many files have changed in this diff Show More