F: Implement crucial constituents feature
Some checks failed
Frontend CI / build (22.x) (push) Waiting to run
Frontend CI / notify-failure (push) Blocked by required conditions
Backend CI / build (3.12) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-07-29 20:56:31 +03:00
parent e4480b158d
commit ba0416c37d
41 changed files with 518 additions and 115 deletions

View File

@ -84,15 +84,18 @@ class TestVersionViews(EndpointTester):
alias='A1', alias='A1',
cst_type='axiom', cst_type='axiom',
definition_formal='X1=X1', definition_formal='X1=X1',
order=1 order=1,
crucial=True
) )
version_id = self._create_version({'version': '1.0.0', 'description': 'test'}) version_id = self._create_version({'version': '1.0.0', 'description': 'test'})
a1.definition_formal = 'X1=X2' a1.definition_formal = 'X1=X2'
a1.crucial = False
a1.save() a1.save()
response = self.executeOK(schema=self.owned_id, version=version_id) response = self.executeOK(schema=self.owned_id, version=version_id)
loaded_a1 = response.data['items'][1] loaded_a1 = response.data['items'][1]
self.assertEqual(loaded_a1['definition_formal'], 'X1=X1') self.assertEqual(loaded_a1['definition_formal'], 'X1=X1')
self.assertEqual(loaded_a1['crucial'], True)
self.assertEqual(loaded_a1['parse']['status'], 'verified') self.assertEqual(loaded_a1['parse']['status'], 'verified')

View File

@ -121,7 +121,8 @@ class TestChangeConstituents(EndpointTester):
'term_raw': 'Test1', 'term_raw': 'Test1',
'definition_formal': r'X4\X4', 'definition_formal': r'X4\X4',
'definition_raw': '@{X5|sing,datv}', 'definition_raw': '@{X5|sing,datv}',
'convention': 'test' 'convention': 'test',
'crucial': True,
} }
} }
response = self.executeOK(data=data, schema=self.ks1.model.pk) response = self.executeOK(data=data, schema=self.ks1.model.pk)
@ -132,9 +133,11 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(self.ks1X1.definition_formal, data['item_data']['definition_formal']) self.assertEqual(self.ks1X1.definition_formal, data['item_data']['definition_formal'])
self.assertEqual(self.ks1X1.definition_raw, data['item_data']['definition_raw']) self.assertEqual(self.ks1X1.definition_raw, data['item_data']['definition_raw'])
self.assertEqual(self.ks1X1.convention, data['item_data']['convention']) self.assertEqual(self.ks1X1.convention, data['item_data']['convention'])
self.assertEqual(self.ks1X1.crucial, data['item_data']['crucial'])
self.assertEqual(d2.definition_resolved, data['item_data']['term_raw']) self.assertEqual(d2.definition_resolved, data['item_data']['term_raw'])
self.assertEqual(inherited_cst.term_raw, data['item_data']['term_raw']) self.assertEqual(inherited_cst.term_raw, data['item_data']['term_raw'])
self.assertEqual(inherited_cst.convention, data['item_data']['convention']) self.assertEqual(inherited_cst.convention, data['item_data']['convention'])
self.assertEqual(inherited_cst.crucial, False)
self.assertEqual(inherited_cst.definition_formal, r'X1\X1') self.assertEqual(inherited_cst.definition_formal, r'X1\X1')
self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}') self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}')

View File

@ -8,5 +8,5 @@ from . import models
class ConstituentaAdmin(admin.ModelAdmin): class ConstituentaAdmin(admin.ModelAdmin):
''' Admin model: Constituenta. ''' ''' Admin model: Constituenta. '''
ordering = ['schema', 'order'] ordering = ['schema', 'order']
list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved'] list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved', 'crucial']
search_fields = ['term_resolved', 'definition_resolved'] search_fields = ['term_resolved', 'definition_resolved']

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-07-29 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rsform', '0003_alter_constituenta_order'),
]
operations = [
migrations.AddField(
model_name='constituenta',
name='crucial',
field=models.BooleanField(default=False, verbose_name='Ключевая'),
),
]

View File

@ -4,6 +4,7 @@ import re
from cctext import extract_entities from cctext import extract_entities
from django.db.models import ( from django.db.models import (
CASCADE, CASCADE,
BooleanField,
CharField, CharField,
ForeignKey, ForeignKey,
JSONField, JSONField,
@ -103,6 +104,10 @@ class Constituenta(Model):
default='', default='',
blank=True blank=True
) )
crucial = BooleanField(
verbose_name='Ключевая',
default=False
)
class Meta: class Meta:
''' Model metadata. ''' ''' Model metadata. '''

View File

@ -144,6 +144,7 @@ class RSForm:
self.cache.ensure_loaded() self.cache.ensure_loaded()
position = self.cache.constituents.index(self.cache.by_id[insert_after.pk]) + 1 position = self.cache.constituents.index(self.cache.by_id[insert_after.pk]) + 1
result = self.insert_new(data['alias'], data['cst_type'], position) result = self.insert_new(data['alias'], data['cst_type'], position)
result.crucial = data.get('crucial', False)
result.convention = data.get('convention', '') result.convention = data.get('convention', '')
result.definition_formal = data.get('definition_formal', '') result.definition_formal = data.get('definition_formal', '')
result.term_forms = data.get('term_forms', []) result.term_forms = data.get('term_forms', [])
@ -247,6 +248,9 @@ class RSForm:
else: else:
old_data['convention'] = cst.convention old_data['convention'] = cst.convention
cst.convention = data['convention'] cst.convention = data['convention']
if 'crucial' in data:
cst.crucial = data['crucial']
del data['crucial']
if 'definition_formal' in data: if 'definition_formal' in data:
if cst.definition_formal == data['definition_formal']: if cst.definition_formal == data['definition_formal']:
del data['definition_formal'] del data['definition_formal']

View File

@ -12,6 +12,7 @@ from .basics import (
WordFormSerializer WordFormSerializer
) )
from .data_access import ( from .data_access import (
CrucialUpdateSerializer,
CstCreateSerializer, CstCreateSerializer,
CstInfoSerializer, CstInfoSerializer,
CstListSerializer, CstListSerializer,

View File

@ -46,12 +46,13 @@ class CstUpdateSerializer(StrictSerializer):
class Meta: class Meta:
''' serializer metadata. ''' ''' serializer metadata. '''
model = Constituenta model = Constituenta
fields = 'alias', 'cst_type', 'convention', 'definition_formal', 'definition_raw', 'term_raw', 'term_forms' fields = 'alias', 'cst_type', 'convention', 'crucial', 'definition_formal', \
'definition_raw', 'term_raw', 'term_forms'
target = PKField( target = PKField(
many=False, many=False,
queryset=Constituenta.objects.all().only( queryset=Constituenta.objects.all().only(
'alias', 'cst_type', 'convention', 'definition_formal', 'definition_raw', 'term_raw') 'alias', 'cst_type', 'convention', 'crucial', 'definition_formal', 'definition_raw', 'term_raw')
) )
item_data = ConstituentaUpdateData() item_data = ConstituentaUpdateData()
@ -71,6 +72,24 @@ class CstUpdateSerializer(StrictSerializer):
return attrs return attrs
class CrucialUpdateSerializer(StrictSerializer):
''' Serializer: update crucial status. '''
target = PKField(
many=True,
queryset=Constituenta.objects.all().only('crucial', 'schema_id')
)
value = serializers.BooleanField()
def validate(self, attrs):
schema = cast(LibraryItem, self.context['schema'])
for cst in attrs['target']:
if schema and cst.schema_id != schema.pk:
raise serializers.ValidationError({
f'{cst.pk}': msg.constituentaNotInRSform(schema.title)
})
return attrs
class CstDetailsSerializer(StrictModelSerializer): class CstDetailsSerializer(StrictModelSerializer):
''' Serializer: Constituenta data including parse. ''' ''' Serializer: Constituenta data including parse. '''
parse = CstParseSerializer() parse = CstParseSerializer()
@ -96,7 +115,7 @@ class CstCreateSerializer(StrictModelSerializer):
''' serializer metadata. ''' ''' serializer metadata. '''
model = Constituenta model = Constituenta
fields = \ fields = \
'alias', 'cst_type', 'convention', \ 'alias', 'cst_type', 'convention', 'crucial', \
'term_raw', 'definition_raw', 'definition_formal', \ 'term_raw', 'definition_raw', 'definition_formal', \
'insert_after', 'term_forms' 'insert_after', 'term_forms'

View File

@ -225,7 +225,9 @@ class TestRSFormViewset(EndpointTester):
'cst_type': CstType.BASE, 'cst_type': CstType.BASE,
'insert_after': x2.pk, 'insert_after': x2.pk,
'term_raw': 'test', 'term_raw': 'test',
'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}] 'term_forms': [{'text': 'form1', 'tags': 'sing,datv'}],
'definition_formal': 'invalid',
'crucial': True
} }
response = self.executeCreated(data=data, item=self.owned_id) response = self.executeCreated(data=data, item=self.owned_id)
self.assertEqual(response.data['new_cst']['alias'], data['alias']) self.assertEqual(response.data['new_cst']['alias'], data['alias'])
@ -233,6 +235,8 @@ class TestRSFormViewset(EndpointTester):
self.assertEqual(x4.order, 2) self.assertEqual(x4.order, 2)
self.assertEqual(x4.term_raw, data['term_raw']) self.assertEqual(x4.term_raw, data['term_raw'])
self.assertEqual(x4.term_forms, data['term_forms']) self.assertEqual(x4.term_forms, data['term_forms'])
self.assertEqual(x4.definition_formal, data['definition_formal'])
self.assertEqual(x4.crucial, data['crucial'])
data = { data = {
'alias': 'X5', 'alias': 'X5',
@ -574,6 +578,19 @@ class TestConstituentaAPI(EndpointTester):
self.assertEqual(self.cst3.definition_resolved, 'form1') self.assertEqual(self.cst3.definition_resolved, 'form1')
self.assertEqual(self.cst3.term_forms, data['item_data']['term_forms']) self.assertEqual(self.cst3.term_forms, data['item_data']['term_forms'])
@decl_endpoint('/api/rsforms/{schema}/update-crucial', method='patch')
def test_update_crucial(self):
data = {'target': [self.cst1.pk], 'value': True}
self.executeForbidden(data=data, schema=self.unowned_id)
self.logout()
self.executeForbidden(data=data, schema=self.owned_id)
self.login()
self.executeOK(data=data, schema=self.owned_id)
self.cst1.refresh_from_db()
self.assertEqual(self.cst1.crucial, True)
class TestInlineSynthesis(EndpointTester): class TestInlineSynthesis(EndpointTester):
''' Testing Operations endpoints. ''' ''' Testing Operations endpoints. '''

View File

@ -42,6 +42,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
'load_trs', 'load_trs',
'create_cst', 'create_cst',
'update_cst', 'update_cst',
'update_crucial',
'move_cst', 'move_cst',
'delete_multiple_cst', 'delete_multiple_cst',
'substitute', 'substitute',
@ -137,6 +138,36 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
data=s.RSFormParseSerializer(schema.model).data data=s.RSFormParseSerializer(schema.model).data
) )
@extend_schema(
summary='update crucial attributes of a given list of constituents',
tags=['RSForm'],
request=s.CrucialUpdateSerializer,
responses={
c.HTTP_200_OK: s.RSFormParseSerializer,
c.HTTP_400_BAD_REQUEST: None,
c.HTTP_403_FORBIDDEN: None,
c.HTTP_404_NOT_FOUND: None
}
)
@action(detail=True, methods=['patch'], url_path='update-crucial')
def update_crucial(self, request: Request, pk) -> HttpResponse:
''' Update crucial attributes of a given list of constituents. '''
model = self._get_item()
serializer = s.CrucialUpdateSerializer(data=request.data, partial=True, context={'schema': model})
serializer.is_valid(raise_exception=True)
value: bool = serializer.validated_data['value']
with transaction.atomic():
for cst in serializer.validated_data['target']:
cst.crucial = value
cst.save(update_fields=['crucial'])
model.save(update_fields=['time_update'])
return Response(
status=c.HTTP_200_OK,
data=s.RSFormParseSerializer(model).data
)
@extend_schema( @extend_schema(
summary='produce the structure of a given constituenta', summary='produce the structure of a given constituenta',
tags=['RSForm'], tags=['RSForm'],

View File

@ -0,0 +1,37 @@
import { globalIDs } from '@/utils/constants';
import { type Button as ButtonStyle } from '../props';
import { cn } from '../utils';
interface TextButtonProps extends ButtonStyle {
/** Text to display second. */
text: string;
}
/**
* Customizable `button` with text, transparent background and no additional styling.
*/
export function TextButton({ text, title, titleHtml, hideTitle, className, ...restProps }: TextButtonProps) {
return (
<button
tabIndex={-1}
type='button'
className={cn(
'self-start cc-label cc-hover-underline',
'font-medium text-primary select-none disabled:text-foreground',
'cursor-pointer disabled:cursor-default',
'outline-hidden',
'select-text',
className
)}
data-tooltip-id={!!title || !!titleHtml ? globalIDs.tooltip : undefined}
data-tooltip-html={titleHtml}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle}
aria-label={!text ? title : undefined}
{...restProps}
>
{text}
</button>
);
}

View File

@ -30,7 +30,7 @@ export function TextURL({ text, href, title, color = 'text-primary', onClick }:
); );
} else if (onClick) { } else if (onClick) {
return ( return (
<button type='button' tabIndex={-1} className={design} onClick={onClick}> <button type='button' tabIndex={-1} className={design} title={title} onClick={onClick}>
{text} {text}
</button> </button>
); );

View File

@ -106,9 +106,9 @@ export { LuDatabase as IconDatabase } from 'react-icons/lu';
export { LuView as IconDBStructure } from 'react-icons/lu'; export { LuView as IconDBStructure } from 'react-icons/lu';
export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu'; export { LuPlaneTakeoff as IconRESTapi } from 'react-icons/lu';
export { LuImage as IconImage } from 'react-icons/lu'; export { LuImage as IconImage } from 'react-icons/lu';
export { PiFediverseLogo as IconGraphSelection } from 'react-icons/pi';
export { GoVersions as IconVersions } from 'react-icons/go'; export { GoVersions as IconVersions } from 'react-icons/go';
export { LuAtSign as IconTerm } from 'react-icons/lu'; export { LuAtSign as IconTerm } from 'react-icons/lu';
export { MdTaskAlt as IconCrucial } from 'react-icons/md';
export { LuSubscript as IconAlias } from 'react-icons/lu'; export { LuSubscript as IconAlias } from 'react-icons/lu';
export { TbMathFunction as IconFormula } from 'react-icons/tb'; export { TbMathFunction as IconFormula } from 'react-icons/tb';
export { BiFontFamily as IconText } from 'react-icons/bi'; export { BiFontFamily as IconText } from 'react-icons/bi';
@ -150,9 +150,11 @@ export { GrConnect as IconConnect } from 'react-icons/gr';
export { BiPlayCircle as IconExecute } from 'react-icons/bi'; export { BiPlayCircle as IconExecute } from 'react-icons/bi';
// ======== Graph UI ======= // ======== Graph UI =======
export { PiFediverseLogo as IconContextSelection } from 'react-icons/pi';
export { ImMakeGroup as IconGroupSelection } from 'react-icons/im';
export { BiCollapse as IconGraphCollapse } from 'react-icons/bi'; export { BiCollapse as IconGraphCollapse } from 'react-icons/bi';
export { BiExpand as IconGraphExpand } from 'react-icons/bi'; export { BiExpand as IconGraphExpand } from 'react-icons/bi';
export { LuMaximize as IconGraphMaximize } from 'react-icons/lu'; export { TiArrowMaximise as IconGraphMaximize } from 'react-icons/ti';
export { BiGitBranch as IconGraphInputs } from 'react-icons/bi'; export { BiGitBranch as IconGraphInputs } from 'react-icons/bi';
export { TbEarScan as IconGraphInverse } from 'react-icons/tb'; export { TbEarScan as IconGraphInverse } from 'react-icons/tb';
export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi'; export { BiGitMerge as IconGraphOutputs } from 'react-icons/bi';

View File

@ -40,12 +40,20 @@ export function generateSample(target: string): string {
export function varSchema(schema: IRSForm): string { export function varSchema(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`; let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`; result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Понятия:\n'; result += 'Конституенты:\n';
schema.items.forEach(item => { schema.items.forEach(item => {
result += `\n${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${ result += `\n${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
item.definition_formal item.definition_formal
}" - "${item.definition_resolved}" - "${item.convention}"`; }" - "${item.definition_resolved}" - "${item.convention}"`;
}); });
if (schema.stats.count_crucial > 0) {
result +=
'\nКлючевые конституенты: ' +
schema.items
.filter(cst => cst.crucial)
.map(cst => cst.alias)
.join(', ');
}
return result; return result;
} }

View File

@ -1,6 +1,7 @@
import { import {
IconChild, IconChild,
IconConsolidation, IconConsolidation,
IconCrucial,
IconCstAxiom, IconCstAxiom,
IconCstBaseSet, IconCstBaseSet,
IconCstConstSet, IconCstConstSet,
@ -91,6 +92,11 @@ export function HelpThesaurus() {
родоструктурной экспликации являются Термин, Конвенция, Типизация (Структура), Формальное определение, Текстовое родоструктурной экспликации являются Термин, Конвенция, Типизация (Структура), Формальное определение, Текстовое
определение, Комментарий. определение, Комментарий.
</p> </p>
<p>
<IconCrucial size='1rem' className='inline-icon' /> Ключевая конституента используется как маркер для
обозначения содержательно значимых конституент. Ключевые конституенты выделяются визуально и используются при
фильтрации.
</p>
<br /> <br />

View File

@ -1,10 +1,10 @@
import { import {
IconChild, IconChild,
IconClone, IconClone,
IconContextSelection,
IconCrucial,
IconDestroy, IconDestroy,
IconEdit,
IconFilter, IconFilter,
IconGraphSelection,
IconKeyboard, IconKeyboard,
IconLeftOpen, IconLeftOpen,
IconMoveDown, IconMoveDown,
@ -27,6 +27,13 @@ export function HelpRSEditor() {
return ( return (
<div className='dense'> <div className='dense'>
<h1>Редактор конституенты</h1> <h1>Редактор конституенты</h1>
<ul>
<li>
<IconCrucial className='inline-icon' /> статус ключевой конституенты
</li>
</ul>
<div className='flex flex-col sm:flex-row sm:gap-3'> <div className='flex flex-col sm:flex-row sm:gap-3'>
<div> <div>
<h2>Команды</h2> <h2>Команды</h2>
@ -69,7 +76,7 @@ export function HelpRSEditor() {
<IconFilter className='inline-icon' /> фильтрация по атрибутам <IconFilter className='inline-icon' /> фильтрация по атрибутам
</li> </li>
<li> <li>
<IconGraphSelection className='inline-icon' /> фильтрация по графу термов <IconContextSelection className='inline-icon' /> фильтрация по графу термов
</li> </li>
<li> <li>
<IconChild className='inline-icon' /> отображение наследованных <IconChild className='inline-icon' /> отображение наследованных
@ -114,8 +121,7 @@ export function HelpRSEditor() {
<h2>Термин и Текстовое определение</h2> <h2>Термин и Текстовое определение</h2>
<ul> <ul>
<li> <li>
<IconEdit className='inline-icon' /> редактирование{' '} <kbd>Клик</kbd> редактирование <LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} /> /{' '}
<LinkTopic text='Имени' topic={HelpTopic.CC_CONSTITUENTA} /> /{' '}
<LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} /> <LinkTopic text='Термина' topic={HelpTopic.CC_CONSTITUENTA} />
</li> </li>
<li> <li>

View File

@ -1,6 +1,9 @@
import { Divider } from '@/components/container'; import { Divider } from '@/components/container';
import { import {
IconChild,
IconClustering, IconClustering,
IconContextSelection,
IconCrucial,
IconDestroy, IconDestroy,
IconEdit, IconEdit,
IconFilter, IconFilter,
@ -12,7 +15,7 @@ import {
IconGraphInputs, IconGraphInputs,
IconGraphMaximize, IconGraphMaximize,
IconGraphOutputs, IconGraphOutputs,
IconGraphSelection, IconGroupSelection,
IconNewItem, IconNewItem,
IconOSS, IconOSS,
IconPredecessor, IconPredecessor,
@ -103,7 +106,7 @@ export function HelpRSGraphTerm() {
<h2>Выделение</h2> <h2>Выделение</h2>
<ul> <ul>
<li> <li>
<IconGraphSelection className='inline-icon' /> выделить связанные... <IconContextSelection className='inline-icon' /> выделить связанные...
</li> </li>
<li> <li>
<IconGraphCollapse className='inline-icon' /> все влияющие <IconGraphCollapse className='inline-icon' /> все влияющие
@ -120,13 +123,23 @@ export function HelpRSGraphTerm() {
<li> <li>
<IconGraphOutputs className='inline-icon' /> исходящие напрямую <IconGraphOutputs className='inline-icon' /> исходящие напрямую
</li> </li>
<li>
<IconGroupSelection className='inline-icon' /> выделить группы...
</li>
<li> <li>
<IconGraphCore className='inline-icon' /> выделить <LinkTopic text='Ядро' topic={HelpTopic.CC_SYSTEM} /> <IconGraphCore className='inline-icon' /> выделить <LinkTopic text='Ядро' topic={HelpTopic.CC_SYSTEM} />
</li> </li>
<li>
<IconCrucial className='inline-icon' /> выделить ключевые
</li>
<li> <li>
<IconPredecessor className='inline-icon' /> выделить{' '} <IconPredecessor className='inline-icon' /> выделить{' '}
<LinkTopic text='собственные' topic={HelpTopic.CC_PROPAGATION} /> <LinkTopic text='собственные' topic={HelpTopic.CC_PROPAGATION} />
</li> </li>
<li>
<IconChild className='inline-icon' /> выделить{' '}
<LinkTopic text='наследники' topic={HelpTopic.CC_PROPAGATION} />
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -77,6 +77,7 @@ export function ToolbarSchema({
const targetType = activeCst?.cst_type ?? CstType.BASE; const targetType = activeCst?.cst_type ?? CstType.BASE;
const data: ICreateConstituentaDTO = { const data: ICreateConstituentaDTO = {
insert_after: activeCst?.id ?? null, insert_after: activeCst?.id ?? null,
crucial: false,
cst_type: targetType, cst_type: targetType,
alias: generateAlias(targetType, schema), alias: generateAlias(targetType, schema),
term_raw: '', term_raw: '',
@ -96,6 +97,7 @@ export function ToolbarSchema({
itemID: schema.id, itemID: schema.id,
data: { data: {
insert_after: activeCst.id, insert_after: activeCst.id,
crucial: activeCst.crucial,
cst_type: activeCst.cst_type, cst_type: activeCst.cst_type,
alias: generateAlias(activeCst.cst_type, schema), alias: generateAlias(activeCst.cst_type, schema),
term_raw: activeCst.term_raw, term_raw: activeCst.term_raw,

View File

@ -17,6 +17,7 @@ import {
type IRSFormUploadDTO, type IRSFormUploadDTO,
type ISubstitutionsDTO, type ISubstitutionsDTO,
type IUpdateConstituentaDTO, type IUpdateConstituentaDTO,
type IUpdateCrucialDTO,
schemaConstituentaCreatedResponse, schemaConstituentaCreatedResponse,
schemaExpressionParse, schemaExpressionParse,
schemaProduceStructureResponse, schemaProduceStructureResponse,
@ -79,6 +80,15 @@ export const rsformsApi = {
successMessage: infoMsg.changesSaved successMessage: infoMsg.changesSaved
} }
}), }),
updateCrucial: ({ itemID, data }: { itemID: number; data: IUpdateCrucialDTO }) =>
axiosPatch<IUpdateCrucialDTO, IRSFormDTO>({
schema: schemaRSForm,
endpoint: `/api/rsforms/${itemID}/update-crucial`,
request: {
data: data,
successMessage: infoMsg.changesSaved
}
}),
deleteConstituents: ({ itemID, data }: { itemID: number; data: IConstituentaList }) => deleteConstituents: ({ itemID, data }: { itemID: number; data: IConstituentaList }) =>
axiosPatch<IConstituentaList, IRSFormDTO>({ axiosPatch<IConstituentaList, IRSFormDTO>({
schema: schemaRSForm, schema: schemaRSForm,

View File

@ -183,6 +183,7 @@ export class RSFormLoader {
const items = this.schema.items; const items = this.schema.items;
return { return {
count_all: items.length, count_all: items.length,
count_crucial: items.reduce((sum, cst) => sum + (cst.crucial ? 1 : 0), 0),
count_errors: items.reduce((sum, cst) => sum + (cst.parse.status === ParsingStatus.INCORRECT ? 1 : 0), 0), count_errors: items.reduce((sum, cst) => sum + (cst.parse.status === ParsingStatus.INCORRECT ? 1 : 0), 0),
count_property: items.reduce((sum, cst) => sum + (cst.parse.valueClass === ValueClass.PROPERTY ? 1 : 0), 0), count_property: items.reduce((sum, cst) => sum + (cst.parse.valueClass === ValueClass.PROPERTY ? 1 : 0), 0),
count_incalculable: items.reduce( count_incalculable: items.reduce(

View File

@ -65,6 +65,9 @@ export type IConstituentaCreatedResponse = z.infer<typeof schemaConstituentaCrea
/** Represents data, used in updating persistent attributes in {@link IConstituenta}. */ /** Represents data, used in updating persistent attributes in {@link IConstituenta}. */
export type IUpdateConstituentaDTO = z.infer<typeof schemaUpdateConstituenta>; export type IUpdateConstituentaDTO = z.infer<typeof schemaUpdateConstituenta>;
/** Represents data, used in batch updating crucial attributes in {@link IConstituenta}. */
export type IUpdateCrucialDTO = z.infer<typeof schemaUpdateCrucial>;
/** Represents data, used in ordering a list of {@link IConstituenta}. */ /** Represents data, used in ordering a list of {@link IConstituenta}. */
export interface IMoveConstituentsDTO { export interface IMoveConstituentsDTO {
items: number[]; items: number[];
@ -276,6 +279,7 @@ export const schemaConstituentaBasics = z.strictObject({
id: z.coerce.number(), id: z.coerce.number(),
alias: z.string().nonempty(errorMsg.requiredField), alias: z.string().nonempty(errorMsg.requiredField),
convention: z.string(), convention: z.string(),
crucial: z.boolean(),
cst_type: schemaCstType, cst_type: schemaCstType,
definition_formal: z.string(), definition_formal: z.string(),
definition_raw: z.string(), definition_raw: z.string(),
@ -321,7 +325,8 @@ export const schemaVersionCreatedResponse = z.strictObject({
export const schemaCreateConstituenta = schemaConstituentaBasics export const schemaCreateConstituenta = schemaConstituentaBasics
.pick({ .pick({
cst_type: true, cst_type: true,
term_forms: true term_forms: true,
crucial: true
}) })
.extend({ .extend({
alias: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField), alias: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField),
@ -342,6 +347,7 @@ export const schemaUpdateConstituenta = z.strictObject({
item_data: z.strictObject({ item_data: z.strictObject({
alias: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField).optional(), alias: z.string().max(limits.len_alias, errorMsg.aliasLength).nonempty(errorMsg.requiredField).optional(),
cst_type: schemaCstType.optional(), cst_type: schemaCstType.optional(),
crucial: z.boolean().optional(),
convention: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(), convention: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(),
definition_formal: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(), definition_formal: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(),
definition_raw: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(), definition_raw: z.string().max(limits.len_description, errorMsg.descriptionLength).optional(),
@ -357,6 +363,11 @@ export const schemaUpdateConstituenta = z.strictObject({
}) })
}); });
export const schemaUpdateCrucial = z.strictObject({
target: z.array(z.number()),
value: z.boolean()
});
export const schemaProduceStructureResponse = z.strictObject({ export const schemaProduceStructureResponse = z.strictObject({
cst_list: z.array(z.number()), cst_list: z.array(z.number()),
schema: schemaRSForm schema: schemaRSForm

View File

@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp';
import { KEYS } from '@/backend/configuration';
import { rsformsApi } from './api';
import { type IUpdateCrucialDTO } from './types';
export const useUpdateCrucial = () => {
const client = useQueryClient();
const { updateTimestamp } = useUpdateTimestamp();
const mutation = useMutation({
mutationKey: [KEYS.global_mutation, rsformsApi.baseKey, 'update-crucial'],
mutationFn: rsformsApi.updateCrucial,
onSuccess: data => {
updateTimestamp(data.id, data.time_update);
client.setQueryData(rsformsApi.getRSFormQueryOptions({ itemID: data.id }).queryKey, data);
},
onError: () => client.invalidateQueries()
});
return {
updateCrucial: (data: { itemID: number; data: IUpdateCrucialDTO }) => mutation.mutateAsync(data)
};
};

View File

@ -26,6 +26,7 @@ export function BadgeConstituenta({ value, prefixID }: BadgeConstituentaProps) {
className={clsx( className={clsx(
'cc-badge-constituenta', 'cc-badge-constituenta',
value.is_inherited && 'border-dashed', value.is_inherited && 'border-dashed',
value.crucial && 'cc-badge-inner-shadow',
value.cst_class === CstClass.BASIC ? 'bg-accent-green25' : 'bg-input' value.cst_class === CstClass.BASIC ? 'bg-accent-green25' : 'bg-input'
)} )}
style={{ style={{

View File

@ -0,0 +1,10 @@
import { type DomIconProps, IconCrucial } from '@/components/icons';
import { cn } from '@/components/utils';
export function IconCrucialValue({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconCrucial size={size} className={cn('text-primary', className)} />;
} else {
return <IconCrucial size={size} className={cn('text-muted-foreground', className)} />;
}
}

View File

@ -1,10 +1,10 @@
import { import {
type DomIconProps, type DomIconProps,
IconContextSelection,
IconGraphCollapse, IconGraphCollapse,
IconGraphExpand, IconGraphExpand,
IconGraphInputs, IconGraphInputs,
IconGraphOutputs, IconGraphOutputs
IconGraphSelection
} from '@/components/icons'; } from '@/components/icons';
import { DependencyMode } from '../stores/cst-search'; import { DependencyMode } from '../stores/cst-search';
@ -13,7 +13,7 @@ import { DependencyMode } from '../stores/cst-search';
export function IconDependencyMode({ value, size = '1.25rem', className }: DomIconProps<DependencyMode>) { export function IconDependencyMode({ value, size = '1.25rem', className }: DomIconProps<DependencyMode>) {
switch (value) { switch (value) {
case DependencyMode.ALL: case DependencyMode.ALL:
return <IconGraphSelection size={size} className={className} />; return <IconContextSelection size={size} className={className} />;
case DependencyMode.OUTPUTS: case DependencyMode.OUTPUTS:
return <IconGraphOutputs size={size} className={className ?? 'text-primary'} />; return <IconGraphOutputs size={size} className={className ?? 'text-primary'} />;
case DependencyMode.INPUTS: case DependencyMode.INPUTS:

View File

@ -1,4 +1,4 @@
import { IconChild } from '@/components/icons'; import { IconChild, IconCrucial } from '@/components/icons';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { labelCstTypification } from '../labels'; import { labelCstTypification } from '../labels';
@ -15,6 +15,7 @@ export function InfoConstituenta({ data, className, ...restProps }: InfoConstitu
<h2 className='cursor-default' title={data.is_inherited ? ' наследник' : undefined}> <h2 className='cursor-default' title={data.is_inherited ? ' наследник' : undefined}>
{data.alias} {data.alias}
{data.is_inherited ? <IconChild size='1rem' className='inline-icon align-middle ml-1 mt-1' /> : null} {data.is_inherited ? <IconChild size='1rem' className='inline-icon align-middle ml-1 mt-1' /> : null}
{data.crucial ? <IconCrucial size='1rem' className='inline-icon align-middle mt-1' /> : null}
</h2> </h2>
{data.term_resolved ? ( {data.term_resolved ? (
<p> <p>

View File

@ -116,7 +116,8 @@ export function PickMultiConstituenta({
const cst = schema.cstByID.get(cstID); const cst = schema.cstByID.get(cstID);
return !!cst && isBasicConcept(cst.cst_type); return !!cst && isBasicConcept(cst.cst_type);
}} }}
isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited} isCrucial={cstID => schema.cstByID.get(cstID)?.crucial ?? false}
isInherited={cstID => schema.cstByID.get(cstID)?.is_inherited ?? false}
value={value} value={value}
onChange={onChange} onChange={onChange}
className='w-fit' className='w-fit'

View File

@ -1,6 +1,7 @@
import { import {
IconChild, IconChild,
IconConvention, IconConvention,
IconCrucial,
IconCstAxiom, IconCstAxiom,
IconCstBaseSet, IconCstBaseSet,
IconCstConstSet, IconCstConstSet,
@ -113,6 +114,12 @@ export function RSFormStats({ className, stats }: RSFormStatsProps) {
value={stats.count_theorem} value={stats.count_theorem}
/> />
<ValueStats
id='count_crucial'
title='Ключевые'
icon={<IconCrucial size='1.25rem' />}
value={stats.count_crucial}
/>
<ValueStats <ValueStats
id='count_text_term' id='count_text_term'
title='Термины' title='Термины'

View File

@ -30,6 +30,7 @@ export function TGNode(node: TGNodeInternal) {
<div <div
className={clsx( className={clsx(
'w-full h-full cursor-default flex items-center justify-center rounded-full', 'w-full h-full cursor-default flex items-center justify-center rounded-full',
node.data.cst.crucial && 'text-primary',
node.data.focused && 'border-[2px] border-selected', node.data.focused && 'border-[2px] border-selected',
label.length > LABEL_THRESHOLD ? 'text-[12px]/[16px]' : 'text-[14px]/[20px]' label.length > LABEL_THRESHOLD ? 'text-[12px]/[16px]' : 'text-[14px]/[20px]'
)} )}
@ -50,6 +51,7 @@ export function TGNode(node: TGNodeInternal) {
{description ? ( {description ? (
<div <div
className={clsx( className={clsx(
node.data.cst.crucial && 'text-primary',
'mt-[4px] w-[150px] px-[4px] text-center translate-x-[calc(-50%+20px)]', 'mt-[4px] w-[150px] px-[4px] text-center translate-x-[calc(-50%+20px)]',
'pointer-events-none', 'pointer-events-none',
description.length > DESCRIPTION_THRESHOLD ? 'text-[10px]/[12px]' : 'text-[12px]/[16px]' description.length > DESCRIPTION_THRESHOLD ? 'text-[10px]/[12px]' : 'text-[12px]/[16px]'
@ -69,9 +71,11 @@ export function TGNode(node: TGNodeInternal) {
// ====== INTERNAL ====== // ====== INTERNAL ======
function describeCstNode(cst: IConstituenta) { function describeCstNode(cst: IConstituenta) {
return `${cst.alias}: ${cst.term_resolved}</br><b>Типизация:</b> ${labelCstTypification( const contents = isBasicConcept(cst.cst_type)
cst ? cst.convention
)}</br><b>Содержание:</b> ${ : cst.definition_resolved || cst.definition_formal || cst.convention;
isBasicConcept(cst.cst_type) ? cst.convention : cst.definition_resolved || cst.definition_formal || cst.convention const typification = labelCstTypification(cst);
return `${cst.alias}: ${cst.term_resolved}</br><b>Типизация:</b> ${typification}</br><b>Содержание:</b> ${
contents ? contents : 'отсутствует'
}`; }`;
} }

View File

@ -1,6 +1,9 @@
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
import { import {
IconChild,
IconContextSelection,
IconCrucial,
IconGraphCollapse, IconGraphCollapse,
IconGraphCore, IconGraphCore,
IconGraphExpand, IconGraphExpand,
@ -8,7 +11,7 @@ import {
IconGraphInverse, IconGraphInverse,
IconGraphMaximize, IconGraphMaximize,
IconGraphOutputs, IconGraphOutputs,
IconGraphSelection, IconGroupSelection,
IconPredecessor, IconPredecessor,
IconReset IconReset
} from '@/components/icons'; } from '@/components/icons';
@ -21,7 +24,8 @@ interface ToolbarGraphSelectionProps extends Styling {
onChange: (newSelection: number[]) => void; onChange: (newSelection: number[]) => void;
graph: Graph; graph: Graph;
isCore: (item: number) => boolean; isCore: (item: number) => boolean;
isOwned?: (item: number) => boolean; isCrucial: (item: number) => boolean;
isInherited: (item: number) => boolean;
} }
export function ToolbarGraphSelection({ export function ToolbarGraphSelection({
@ -29,20 +33,66 @@ export function ToolbarGraphSelection({
graph, graph,
value: selected, value: selected,
isCore, isCore,
isOwned, isInherited,
isCrucial,
onChange, onChange,
...restProps ...restProps
}: ToolbarGraphSelectionProps) { }: ToolbarGraphSelectionProps) {
const menu = useDropdown(); const selectedMenu = useDropdown();
const groupMenu = useDropdown();
const emptySelection = selected.length === 0; const emptySelection = selected.length === 0;
function handleSelectReset() {
onChange([]);
}
function handleSelectCore() { function handleSelectCore() {
groupMenu.hide();
const core = [...graph.nodes.keys()].filter(isCore); const core = [...graph.nodes.keys()].filter(isCore);
onChange([...core, ...graph.expandInputs(core)]); onChange([...core, ...graph.expandInputs(core)]);
} }
function handleSelectOwned() { function handleSelectOwned() {
if (isOwned) onChange([...graph.nodes.keys()].filter(isOwned)); groupMenu.hide();
onChange([...graph.nodes.keys()].filter((item: number) => !isInherited(item)));
}
function handleSelectInherited() {
groupMenu.hide();
onChange([...graph.nodes.keys()].filter(isInherited));
}
function handleSelectCrucial() {
groupMenu.hide();
onChange([...graph.nodes.keys()].filter(isCrucial));
}
function handleExpandOutputs() {
onChange([...selected, ...graph.expandOutputs(selected)]);
}
function handleExpandInputs() {
onChange([...selected, ...graph.expandInputs(selected)]);
}
function handleSelectMaximize() {
selectedMenu.hide();
onChange(graph.maximizePart(selected));
}
function handleSelectInvert() {
selectedMenu.hide();
onChange([...graph.nodes.keys()].filter(item => !selected.includes(item)));
}
function handleSelectAllInputs() {
selectedMenu.hide();
onChange([...graph.expandInputs(selected)]);
}
function handleSelectAllOutputs() {
selectedMenu.hide();
onChange([...graph.expandOutputs(selected)]);
} }
return ( return (
@ -50,73 +100,99 @@ export function ToolbarGraphSelection({
<MiniButton <MiniButton
title='Сбросить выделение' title='Сбросить выделение'
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => onChange([])} onClick={handleSelectReset}
disabled={emptySelection} disabled={emptySelection}
/> />
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center relative'>
<div ref={selectedMenu.ref} onBlur={selectedMenu.handleBlur} className='flex items-center relative'>
<MiniButton <MiniButton
title='Выделить...' title='Выделить на основе выбранных...'
hideTitle={menu.isOpen} hideTitle={selectedMenu.isOpen}
icon={<IconGraphSelection size='1.25rem' className='icon-primary' />} icon={<IconContextSelection size='1.25rem' className='icon-primary' />}
onClick={menu.toggle} onClick={selectedMenu.toggle}
disabled={emptySelection} disabled={emptySelection}
/> />
<Dropdown isOpen={menu.isOpen} className='-translate-x-1/2'> <Dropdown isOpen={selectedMenu.isOpen} className='-translate-x-1/2'>
<DropdownButton
text='Влияющие'
title='Выделить все влияющие'
icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />}
onClick={() => onChange([...selected, ...graph.expandAllInputs(selected)])}
disabled={emptySelection}
/>
<DropdownButton
text='Зависимые'
title='Выделить все зависимые'
icon={<IconGraphExpand size='1.25rem' className='icon-primary' />}
onClick={() => onChange([...selected, ...graph.expandAllOutputs(selected)])}
disabled={emptySelection}
/>
<DropdownButton <DropdownButton
text='Поставщики' text='Поставщики'
title='Выделить поставщиков' title='Выделить поставщиков'
icon={<IconGraphInputs size='1.25rem' className='icon-primary' />} icon={<IconGraphInputs size='1.25rem' className='icon-primary' />}
onClick={() => onChange([...selected, ...graph.expandInputs(selected)])} onClick={handleExpandInputs}
disabled={emptySelection} disabled={emptySelection}
/> />
<DropdownButton <DropdownButton
text='Потребители' text='Потребители'
title='Выделить потребителей' title='Выделить потребителей'
icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />} icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />}
onClick={() => onChange([...selected, ...graph.expandOutputs(selected)])} onClick={handleExpandOutputs}
disabled={emptySelection} disabled={emptySelection}
/> />
<DropdownButton
text='Влияющие'
title='Выделить все влияющие'
icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />}
onClick={handleSelectAllInputs}
disabled={emptySelection}
/>
<DropdownButton
text='Зависимые'
title='Выделить все зависимые'
icon={<IconGraphExpand size='1.25rem' className='icon-primary' />}
onClick={handleSelectAllOutputs}
disabled={emptySelection}
/>
<DropdownButton <DropdownButton
text='Максимизация' text='Максимизация'
titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных' titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных'
aria-label='Максимизация - дополнение выделения конституентами, зависимыми только от выделенных' aria-label='Максимизация - дополнение выделения конституентами, зависимыми только от выделенных'
icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />} icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />}
onClick={() => onChange(graph.maximizePart(selected))} onClick={handleSelectMaximize}
disabled={emptySelection} disabled={emptySelection}
/> />
<DropdownButton
text='Инвертировать'
icon={<IconGraphInverse size='1.25rem' className='icon-primary' />}
onClick={handleSelectInvert}
/>
</Dropdown> </Dropdown>
</div> </div>
<MiniButton <div ref={groupMenu.ref} onBlur={groupMenu.handleBlur} className='flex items-center relative'>
title='Выделить ядро' <MiniButton
icon={<IconGraphCore size='1.25rem' className='icon-primary' />} title='Выделить группу...'
onClick={handleSelectCore} hideTitle={groupMenu.isOpen}
/> icon={<IconGroupSelection size='1.25rem' className='icon-primary' />}
<MiniButton onClick={groupMenu.toggle}
title='Выделить собственные' />
icon={<IconPredecessor size='1.25rem' className='icon-primary' />} <Dropdown isOpen={groupMenu.isOpen} className='-translate-x-1/2'>
onClick={handleSelectOwned} <DropdownButton
/> text='ядро'
<MiniButton title='Выделить ядро'
title='Инвертировать' icon={<IconGraphCore size='1.25rem' className='icon-primary' />}
icon={<IconGraphInverse size='1.25rem' className='icon-primary' />} onClick={handleSelectCore}
onClick={() => onChange([...graph.nodes.keys()].filter(item => !selected.includes(item)))} />
/> <DropdownButton
text='ключевые'
title='Выделить ключевые'
icon={<IconCrucial size='1.25rem' className='icon-primary' />}
onClick={handleSelectCrucial}
/>
<DropdownButton
text='собственные'
title='Выделить собственные'
icon={<IconPredecessor size='1.25rem' className='icon-primary' />}
onClick={handleSelectOwned}
/>
<DropdownButton
text='наследники'
title='Выделить наследников'
icon={<IconChild size='1.25rem' className='icon-primary' />}
onClick={handleSelectInherited}
/>
</Dropdown>
</div>
</div> </div>
); );
} }

View File

@ -6,9 +6,11 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { MiniButton } from '@/components/control';
import { TextArea, TextInput } from '@/components/input'; import { TextArea, TextInput } from '@/components/input';
import { CstType, type ICreateConstituentaDTO } from '../../backend/types'; import { CstType, type ICreateConstituentaDTO } from '../../backend/types';
import { IconCrucialValue } from '../../components/icon-crucial-value';
import { RSInput } from '../../components/rs-input'; import { RSInput } from '../../components/rs-input';
import { SelectCstType } from '../../components/select-cst-type'; import { SelectCstType } from '../../components/select-cst-type';
import { getRSDefinitionPlaceholder } from '../../labels'; import { getRSDefinitionPlaceholder } from '../../labels';
@ -30,6 +32,7 @@ export function FormCreateCst({ schema }: FormCreateCstProps) {
const cst_type = useWatch({ control, name: 'cst_type' }); const cst_type = useWatch({ control, name: 'cst_type' });
const convention = useWatch({ control, name: 'convention' }); const convention = useWatch({ control, name: 'convention' });
const crucial = useWatch({ control, name: 'crucial' });
const isBasic = isBasicConcept(cst_type); const isBasic = isBasicConcept(cst_type);
const isElementary = isBaseSet(cst_type); const isElementary = isBaseSet(cst_type);
const isFunction = isFunctional(cst_type); const isFunction = isFunctional(cst_type);
@ -41,9 +44,18 @@ export function FormCreateCst({ schema }: FormCreateCstProps) {
setForceComment(false); setForceComment(false);
} }
function handleToggleCrucial() {
setValue('crucial', !crucial);
}
return ( return (
<> <>
<div className='flex items-center self-center gap-3'> <div className='flex items-center self-center gap-3'>
<MiniButton
title='Ключевая конституента'
icon={<IconCrucialValue size='1.25rem' value={crucial} />}
onClick={handleToggleCrucial}
/>
<SelectCstType <SelectCstType
id='dlg_cst_type' // id='dlg_cst_type' //
value={cst_type} value={cst_type}

View File

@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { MiniButton } from '@/components/control';
import { TextArea, TextInput } from '@/components/input'; import { TextArea, TextInput } from '@/components/input';
import { CstType, type IUpdateConstituentaDTO } from '../../backend/types'; import { CstType, type IUpdateConstituentaDTO } from '../../backend/types';
import { IconCrucialValue } from '../../components/icon-crucial-value';
import { SelectCstType } from '../../components/select-cst-type'; import { SelectCstType } from '../../components/select-cst-type';
import { getRSDefinitionPlaceholder, labelCstTypification } from '../../labels'; import { getRSDefinitionPlaceholder, labelCstTypification } from '../../labels';
import { type IConstituenta, type IRSForm } from '../../models/rsform'; import { type IConstituenta, type IRSForm } from '../../models/rsform';
@ -26,6 +28,7 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
const cst_type = useWatch({ control, name: 'item_data.cst_type' }) ?? CstType.BASE; const cst_type = useWatch({ control, name: 'item_data.cst_type' }) ?? CstType.BASE;
const convention = useWatch({ control, name: 'item_data.convention' }); const convention = useWatch({ control, name: 'item_data.convention' });
const crucial = useWatch({ control, name: 'item_data.crucial' }) ?? false;
const isBasic = isBasicConcept(cst_type); const isBasic = isBasicConcept(cst_type);
const isElementary = isBaseSet(cst_type); const isElementary = isBaseSet(cst_type);
const isFunction = isFunctional(cst_type); const isFunction = isFunctional(cst_type);
@ -37,9 +40,18 @@ export function FormEditCst({ target, schema }: FormEditCstProps) {
setForceComment(false); setForceComment(false);
} }
function handleToggleCrucial() {
setValue('item_data.crucial', !crucial);
}
return ( return (
<> <>
<div className='flex items-center self-center gap-3'> <div className='flex items-center self-center gap-3'>
<MiniButton
title='Ключевая конституента'
icon={<IconCrucialValue size='1.25rem' value={crucial} />}
onClick={handleToggleCrucial}
/>
<SelectCstType <SelectCstType
id='dlg_cst_type' // id='dlg_cst_type' //
value={cst_type} value={cst_type}

View File

@ -47,6 +47,7 @@ export interface TermForm {
/** Represents Constituenta. */ /** Represents Constituenta. */
export interface IConstituenta { export interface IConstituenta {
id: number; id: number;
crucial: boolean;
alias: string; alias: string;
convention: string; convention: string;
cst_type: CstType; cst_type: CstType;
@ -102,6 +103,8 @@ export interface IConstituenta {
/** Represents {@link IRSForm} statistics. */ /** Represents {@link IRSForm} statistics. */
export interface IRSFormStats { export interface IRSFormStats {
count_all: number; count_all: number;
count_crucial: number;
count_errors: number; count_errors: number;
count_property: number; count_property: number;
count_incalculable: number; count_incalculable: number;

View File

@ -107,7 +107,7 @@ export function EditorConstituenta() {
isNarrow={isNarrow} isNarrow={isNarrow}
/> />
<div className='mx-0 min-w-140 md:mx-auto pt-8 md:w-195 shrink-0 xs:pt-0'> <div className='mx-0 min-w-120 md:mx-auto pt-8 md:w-195 shrink-0 xs:pt-0'>
{activeCst ? ( {activeCst ? (
<FormConstituenta <FormConstituenta
key={activeCst.id} key={activeCst.id}

View File

@ -6,8 +6,12 @@ import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useUpdateCrucial } from '@/features/rsform/backend/use-update-crucial';
import { IconCrucialValue } from '@/features/rsform/components/icon-crucial-value';
import { MiniButton, SubmitButton } from '@/components/control'; import { MiniButton, SubmitButton } from '@/components/control';
import { IconChild, IconEdit, IconPredecessor, IconSave } from '@/components/icons'; import { TextButton } from '@/components/control/text-button';
import { IconChild, IconPredecessor, IconSave } from '@/components/icons';
import { TextArea } from '@/components/input'; import { TextArea } from '@/components/input';
import { Indicator } from '@/components/view'; import { Indicator } from '@/components/view';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -46,7 +50,8 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
const setIsModified = useModificationStore(state => state.setIsModified); const setIsModified = useModificationStore(state => state.setIsModified);
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
const { updateConstituenta: cstUpdate } = useUpdateConstituenta(); const { updateConstituenta } = useUpdateConstituenta();
const { updateCrucial } = useUpdateCrucial();
const showTypification = useDialogsStore(state => state.showShowTypeGraph); const showTypification = useDialogsStore(state => state.showShowTypeGraph);
const showEditTerm = useDialogsStore(state => state.showEditWordForms); const showEditTerm = useDialogsStore(state => state.showEditWordForms);
const showRenameCst = useDialogsStore(state => state.showRenameCst); const showRenameCst = useDialogsStore(state => state.showRenameCst);
@ -128,7 +133,7 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
} }
function onSubmit(data: IUpdateConstituentaDTO) { function onSubmit(data: IUpdateConstituentaDTO) {
void cstUpdate({ itemID: schema.id, data }).then(() => { void updateConstituenta({ itemID: schema.id, data }).then(() => {
setIsModified(false); setIsModified(false);
reset({ ...data }); reset({ ...data });
}); });
@ -158,33 +163,48 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
showRenameCst({ schema: schema, target: activeCst }); showRenameCst({ schema: schema, target: activeCst });
} }
return ( function handleToggleCrucial() {
<form id={id} className='relative cc-column mt-1 px-6 py-1' onSubmit={event => void handleSubmit(onSubmit)(event)}> void updateCrucial({
{!disabled || isProcessing ? ( itemID: schema.id,
<MiniButton data: {
title={isModified ? tooltipText.unsaved : 'Редактировать словоформы термина'} target: [activeCst.id],
aria-label='Редактировать словоформы термина' value: !activeCst.crucial
onClick={handleEditTermForms} }
className='absolute z-pop top-0 left-[calc(7ch+4px)]' });
icon={<IconEdit size='1rem' className='icon-primary' />} }
disabled={isModified}
/>
) : null}
<div className='absolute z-pop top-0 left-[calc(7ch+4px+3rem)] flex select-none'> return (
<div className='pt-1 text-sm font-medium min-w-16 whitespace-nowrap select-text cursor-default'> <form
<span>Имя </span> id={id}
<span className='ml-1'>{activeCst?.alias ?? ''}</span> className='relative cc-column mt-1 px-6 pb-1 pt-8'
onSubmit={event => void handleSubmit(onSubmit)(event)}
>
<div className='absolute z-pop top-0 left-6 flex select-text font-medium whitespace-nowrap pt-1'>
<TextButton
text='Термин' //
title={disabled ? undefined : isModified ? tooltipText.unsaved : 'Редактировать словоформы термина'}
onClick={handleEditTermForms}
disabled={isModified || disabled}
/>
<MiniButton
title={activeCst.crucial ? 'Ключевая: да' : 'Ключевая: нет'}
className='ml-6 mr-1 -mt-0.75'
aria-label='Переключатель статуса ключевой конституенты'
icon={<IconCrucialValue size='1rem' value={activeCst.crucial} />}
onClick={handleToggleCrucial}
disabled={disabled || isProcessing || isModified}
/>
<TextButton
text='Имя' //
title={disabled ? undefined : isModified ? tooltipText.unsaved : 'Переименовать конституенту'}
onClick={handleRenameCst}
disabled={isModified || disabled}
/>
<div className='ml-2 text-sm font-medium whitespace-nowrap select-text cursor-default'>
{activeCst?.alias ?? ''}
</div> </div>
{!disabled || isProcessing ? (
<MiniButton
title={isModified ? tooltipText.unsaved : 'Переименовать конституенту'}
aria-label='Переименовать конституенту'
onClick={handleRenameCst}
icon={<IconEdit size='1rem' className='icon-primary' />}
disabled={isModified}
/>
) : null}
</div> </div>
<Controller <Controller
@ -193,7 +213,7 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
render={({ field }) => ( render={({ field }) => (
<RefsInput <RefsInput
id='cst_term' id='cst_term'
label='Термин' aria-label='Термин'
maxHeight='8rem' maxHeight='8rem'
placeholder={disabled ? '' : 'Обозначение для текстовых определений'} placeholder={disabled ? '' : 'Обозначение для текстовых определений'}
schema={schema} schema={schema}
@ -285,14 +305,10 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
) : null} ) : null}
{!showConvention && (!disabled || isProcessing) ? ( {!showConvention && (!disabled || isProcessing) ? (
<button <TextButton
type='button' text='Добавить комментарий' //
tabIndex={-1}
className='self-start cc-label text-primary hover:underline select-none'
onClick={() => setForceComment(true)} onClick={() => setForceComment(true)}
> />
Добавить комментарий
</button>
) : null} ) : null}
{!disabled || isProcessing ? ( {!disabled || isProcessing ? (

View File

@ -41,11 +41,11 @@ export function StatusBar({ className, isModified, processing, activeCst, parseD
})(); })();
return ( return (
<div className={cn('pl-34 xs:pl-8 flex gap-1', className)}> <div className={cn('pl-22 xs:pl-8 flex gap-1', className)}>
<div <div
tabIndex={0} tabIndex={0}
className={clsx( className={clsx(
'w-40 h-7', 'w-32 h-7',
'px-2 flex items-center justify-center', 'px-2 flex items-center justify-center',
'border', 'border',
'select-none', 'select-none',
@ -64,9 +64,9 @@ export function StatusBar({ className, isModified, processing, activeCst, parseD
</div> </div>
) : null} ) : null}
{!processing ? ( {!processing ? (
<div className='cc-fade-in flex items-center gap-2'> <div className='cc-fade-in flex items-center gap-1'>
<IconExpressionStatus size='1rem' value={status} /> <IconExpressionStatus size='1rem' value={status} />
<span className='pb-0.5 font-controls pr-2'>{labelExpressionStatus(status)}</span> <span className='font-controls pr-1 text-sm'>{labelExpressionStatus(status)}</span>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -3,11 +3,13 @@
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components/badge-help'; import { BadgeHelp } from '@/features/help/components/badge-help';
import { MiniSelectorOSS } from '@/features/library/components/mini-selector-oss'; import { MiniSelectorOSS } from '@/features/library/components/mini-selector-oss';
import { useUpdateCrucial } from '@/features/rsform/backend/use-update-crucial';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/dropdown';
import { import {
IconClone, IconClone,
IconCrucial,
IconDestroy, IconDestroy,
IconMoveDown, IconMoveDown,
IconMoveUp, IconMoveUp,
@ -31,10 +33,12 @@ interface ToolbarRSListProps {
export function ToolbarRSList({ className }: ToolbarRSListProps) { export function ToolbarRSList({ className }: ToolbarRSListProps) {
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
const { updateCrucial } = useUpdateCrucial();
const menu = useDropdown(); const menu = useDropdown();
const { const {
schema, schema,
selected, selected,
activeCst,
navigateOss, navigateOss,
deselectAll, deselectAll,
createCst, createCst,
@ -46,6 +50,19 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) {
moveDown moveDown
} = useRSEdit(); } = useRSEdit();
function handleToggleCrucial() {
if (!activeCst) {
return;
}
void updateCrucial({
itemID: schema.id,
data: {
target: selected,
value: !activeCst.crucial
}
});
}
return ( return (
<div className={cn('cc-icons items-start outline-hidden', className)}> <div className={cn('cc-icons items-start outline-hidden', className)}>
{schema.oss.length > 0 ? ( {schema.oss.length > 0 ? (
@ -75,6 +92,13 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) {
onClick={moveDown} onClick={moveDown}
disabled={isProcessing || selected.length === 0 || selected.length === schema.items.length} disabled={isProcessing || selected.length === 0 || selected.length === schema.items.length}
/> />
<MiniButton
title='Ключевая конституента'
aria-label='Переключатель статуса ключевой конституенты'
icon={<IconCrucial size='1.25rem' className='icon-primary' />}
onClick={handleToggleCrucial}
disabled={isProcessing || selected.length === 0}
/>
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'> <div ref={menu.ref} onBlur={menu.handleBlur} className='relative'>
<MiniButton <MiniButton
title='Добавить пустую конституенту' title='Добавить пустую конституенту'

View File

@ -172,7 +172,8 @@ export function ToolbarTermGraph({ className }: ToolbarTermGraphProps) {
const cst = schema.cstByID.get(cstID); const cst = schema.cstByID.get(cstID);
return !!cst && isBasicConcept(cst.cst_type); return !!cst && isBasicConcept(cst.cst_type);
}} }}
isOwned={schema.inheritance.length > 0 ? cstID => !schema.cstByID.get(cstID)?.is_inherited : undefined} isCrucial={cstID => schema.cstByID.get(cstID)?.crucial ?? false}
isInherited={cstID => schema.cstByID.get(cstID)?.is_inherited ?? false}
value={selected} value={selected}
onChange={handleSetSelected} onChange={handleSetSelected}
/> />

View File

@ -80,6 +80,7 @@ export function ViewHidden({ items }: ViewHiddenProps) {
type='button' type='button'
className={clsx( className={clsx(
'cc-view-hidden-item w-12 rounded-md text-center select-none', 'cc-view-hidden-item w-12 rounded-md text-center select-none',
cst.crucial && 'text-primary',
localSelected.includes(cstID) && 'selected', localSelected.includes(cstID) && 'selected',
cst.is_inherited && 'inherited' cst.is_inherited && 'inherited'
)} )}

View File

@ -225,6 +225,7 @@ export const RSEditState = ({
definition_formal: definition ?? '', definition_formal: definition ?? '',
definition_raw: '', definition_raw: '',
convention: '', convention: '',
crucial: false,
term_forms: [] term_forms: []
}; };
if (skipDialog) { if (skipDialog) {
@ -248,6 +249,7 @@ export const RSEditState = ({
definition_formal: activeCst.definition_formal, definition_formal: activeCst.definition_formal,
definition_raw: activeCst.definition_raw, definition_raw: activeCst.definition_raw,
convention: activeCst.convention, convention: activeCst.convention,
crucial: activeCst.crucial,
term_forms: activeCst.term_forms term_forms: activeCst.term_forms
} }
}).then(onCreateCst); }).then(onCreateCst);

View File

@ -40,6 +40,12 @@
} }
} }
@utility cc-hover-underline {
&:hover:not(:disabled) {
text-decoration: underline;
}
}
@utility focus-outline { @utility focus-outline {
--focus-color: var(--color-ring); --focus-color: var(--color-ring);
@ -233,3 +239,7 @@
pointer-events: none; pointer-events: none;
} }
} }
@utility cc-badge-inner-shadow {
box-shadow: inset 0 1px 3px 0, inset 0 -1px 3px 0;
}