npm update and linter fixes

This commit is contained in:
Ivan 2025-10-14 01:07:10 +03:00
parent 6a1b81b130
commit 5139ff12df
37 changed files with 896 additions and 765 deletions

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "stylelint \"src/**/*.css\" && 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 1 --fix",
"preview": "vite preview --port 3000" "preview": "vite preview --port 3000"
}, },
"dependencies": { "dependencies": {
@ -30,57 +30,57 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"global": "^4.4.0", "global": "^4.4.0",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"lucide-react": "^0.542.0", "lucide-react": "^0.545.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.2.0",
"react-dom": "^19.1.1", "react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0", "react-error-boundary": "^6.0.0",
"react-hook-form": "^7.63.0", "react-hook-form": "^7.65.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-intl": "^7.1.11", "react-intl": "^7.1.14",
"react-router": "^7.9.3", "react-router": "^7.9.4",
"react-scan": "^0.4.3", "react-scan": "^0.4.3",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-tooltip": "^5.29.1", "react-tooltip": "^5.30.0",
"react-zoom-pan-pinch": "^3.7.0", "react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
"use-debounce": "^10.0.6", "use-debounce": "^10.0.6",
"zod": "^4.1.11", "zod": "^4.1.12",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.8.0", "@lezer/generator": "^1.8.0",
"@playwright/test": "^1.55.1", "@playwright/test": "^1.56.0",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.14",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.5.2", "@types/node": "^24.7.2",
"@types/react": "^19.1.15", "@types/react": "^19.2.2",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.2.1",
"@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",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^19.1.0-rc.1", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.36.0", "eslint": "^9.37.0",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-playwright": "^2.2.2", "eslint-plugin-playwright": "^2.2.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.1", "eslint-plugin-react-compiler": "^19.1.0-rc.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^16.4.0", "globals": "^16.4.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"stylelint": "^16.24.0", "stylelint": "^16.25.0",
"stylelint-config-recommended": "^16.0.0", "stylelint-config-recommended": "^16.0.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-config-tailwindcss": "^1.0.0", "stylelint-config-tailwindcss": "^1.0.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"ts-jest": "^29.4.4", "ts-jest": "^29.4.5",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.46.0",
"vite": "^7.1.7" "vite": "^7.1.9"
}, },
"jest": { "jest": {
"preset": "ts-jest", "preset": "ts-jest",

View File

@ -14,34 +14,40 @@ import { useConceptNavigation } from './navigation-context';
export function MenuAI() { export function MenuAI() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
const { user } = useAuth(); const { user } = useAuth();
const showAIPrompt = useDialogsStore(state => state.showAIPrompt); const showAIPrompt = useDialogsStore(state => state.showAIPrompt);
function navigateTemplates(event: React.MouseEvent<Element>) { function navigateTemplates(event: React.MouseEvent<Element>) {
menu.hide(); hideMenu();
router.push({ path: urls.prompt_templates, newTab: event.ctrlKey || event.metaKey }); router.push({ path: urls.prompt_templates, newTab: event.ctrlKey || event.metaKey });
} }
function handleCreatePrompt(event: React.MouseEvent<Element>) { function handleCreatePrompt(event: React.MouseEvent<Element>) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
menu.hide(); hideMenu();
showAIPrompt(); showAIPrompt();
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full'> <div ref={menuRef} onBlur={handleMenuBlur} className='flex items-center justify-start relative h-full'>
<NavigationButton <NavigationButton
title='ИИ помощник' // title='ИИ помощник' //
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
aria-expanded={menu.isOpen} aria-expanded={isMenuOpen}
aria-controls={globalIDs.ai_dropdown} aria-controls={globalIDs.ai_dropdown}
icon={<IconAssistant size='1.5rem' />} icon={<IconAssistant size='1.5rem' />}
onClick={menu.toggle} onClick={toggleMenu}
/> />
<Dropdown id={globalIDs.ai_dropdown} className='min-w-[12ch] max-w-48' stretchLeft isOpen={menu.isOpen}> <Dropdown id={globalIDs.ai_dropdown} className='min-w-[12ch] max-w-48' stretchLeft isOpen={isMenuOpen}>
<DropdownButton <DropdownButton
text='Запрос' text='Запрос'
title='Создать запрос' title='Создать запрос'

View File

@ -13,17 +13,23 @@ import { UserDropdown } from './user-dropdown';
export function MenuUser() { export function MenuUser() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='flex items-center justify-start relative h-full'> <div ref={menuRef} onBlur={handleMenuBlur} className='flex items-center justify-start relative h-full'>
<Suspense fallback={<Loader circular scale={1.5} />}> <Suspense fallback={<Loader circular scale={1.5} />}>
<UserButton <UserButton
onLogin={() => router.push({ path: urls.login, force: true })} onLogin={() => router.push({ path: urls.login, force: true })}
onClickUser={menu.toggle} onClickUser={toggleMenu}
isOpen={menu.isOpen} isOpen={isMenuOpen}
/> />
</Suspense> </Suspense>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} /> <UserDropdown isOpen={isMenuOpen} hideDropdown={() => hideMenu()} />
</div> </div>
); );
} }

View File

@ -33,7 +33,7 @@ export function ExportDropdown<T extends object = object>({
filename = 'export', filename = 'export',
className className
}: ExportDropdownProps<T>) { }: ExportDropdownProps<T>) {
const { ref, isOpen, toggle, handleBlur, hide } = useDropdown(); const { elementRef: ref, isOpen, toggle, handleBlur, hide } = useDropdown();
function handleExport(format: 'csv' | 'json') { function handleExport(format: 'csv' | 'json') {
if (!data || data.length === 0) { if (!data || data.length === 0) {

View File

@ -1,4 +1,5 @@
'use client'; 'use client';
'use no memo';
import { useState } from 'react'; import { useState } from 'react';
import { import {

View File

@ -4,11 +4,11 @@ import { useRef, useState } from 'react';
export function useDropdown() { export function useDropdown() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
function handleBlur(event: React.FocusEvent<HTMLDivElement>) { function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
const nextTarget = event.relatedTarget as Node | null; const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && ref.current?.contains(nextTarget)) { if (nextTarget && elementRef.current?.contains(nextTarget)) {
return; return;
} }
@ -23,7 +23,7 @@ export function useDropdown() {
} }
return { return {
ref, elementRef,
isOpen, isOpen,
setIsOpen, setIsOpen,
handleBlur, handleBlur,

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler 'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form'; import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx'; import clsx from 'clsx';
@ -63,6 +63,8 @@ export function FormPromptTemplate({ promptTemplate, className, isMutable, toggl
const prevReset = useRef(toggleReset); const prevReset = useRef(toggleReset);
const prevTemplate = useRef(promptTemplate); const prevTemplate = useRef(promptTemplate);
useEffect(() => {
if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) { if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) {
prevTemplate.current = promptTemplate; prevTemplate.current = promptTemplate;
prevReset.current = toggleReset; prevReset.current = toggleReset;
@ -73,14 +75,13 @@ export function FormPromptTemplate({ promptTemplate, className, isMutable, toggl
text: promptTemplate.text, text: promptTemplate.text,
is_shared: promptTemplate.is_shared is_shared: promptTemplate.is_shared
}); });
setSampleResult(null); return () => setSampleResult(null);
} }
}, [promptTemplate, toggleReset, reset, setSampleResult]);
const prevDirty = useRef(isDirty); useEffect(() => {
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty); setIsModified(isDirty);
} }, [isDirty, setIsModified]);
function onSubmit(data: IUpdatePromptTemplateDTO) { function onSubmit(data: IUpdatePromptTemplateDTO) {
return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => { return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => {

View File

@ -18,19 +18,19 @@ interface TopicsDropdownProps {
} }
export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) { export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
const menu = useDropdown(); const { elementRef, isOpen, toggle, handleBlur, hide } = useDropdown();
const noNavigation = useAppLayoutStore(state => state.noNavigation); const noNavigation = useAppLayoutStore(state => state.noNavigation);
const treeHeight = useFitHeight('4rem + 2px'); const treeHeight = useFitHeight('4rem + 2px');
function handleSelectTopic(topic: HelpTopic) { function handleSelectTopic(topic: HelpTopic) {
menu.hide(); hide();
onChangeTopic(topic); onChangeTopic(topic);
} }
return ( return (
<div <div
ref={menu.ref} ref={elementRef}
onBlur={menu.handleBlur} onBlur={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',
@ -43,10 +43,10 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
<Button <Button
noOutline noOutline
title='Список тем' title='Список тем'
hideTitle={menu.isOpen} hideTitle={isOpen}
icon={!menu.isOpen ? <IconMenuUnfold size='1.25rem' /> : <IconMenuFold size='1.25rem' />} icon={!isOpen ? <IconMenuUnfold size='1.25rem' /> : <IconMenuFold size='1.25rem' />}
className={clsx('w-12 h-7 rounded-none border-l-0', menu.isOpen && 'border-b-0')} className={clsx('w-12 h-7 rounded-none border-l-0', isOpen && 'border-b-0')}
onClick={menu.toggle} onClick={toggle}
/> />
<SelectTree <SelectTree
items={Object.values(HelpTopic).map(item => item as HelpTopic)} items={Object.values(HelpTopic).map(item => item as HelpTopic)}
@ -56,10 +56,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
getParent={item => topicParent.get(item) ?? item} getParent={item => topicParent.get(item) ?? item}
getLabel={labelHelpTopic} getLabel={labelHelpTopic}
getDescription={describeHelpTopic} getDescription={describeHelpTopic}
className={clsx( className={clsx('cc-topic-dropdown border-r border-t rounded-none cc-scroll-y bg-secondary', isOpen && 'open')}
'cc-topic-dropdown border-r border-t rounded-none cc-scroll-y bg-secondary',
menu.isOpen && 'open'
)}
style={{ maxHeight: treeHeight }} style={{ maxHeight: treeHeight }}
/> />
</div> </div>

View File

@ -53,9 +53,15 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
const showEditEditors = useDialogsStore(state => state.showEditEditors); const showEditEditors = useDialogsStore(state => state.showEditEditors);
const showEditLocation = useDialogsStore(state => state.showChangeLocation); const showEditLocation = useDialogsStore(state => state.showChangeLocation);
const ownerSelector = useDropdown(); const {
elementRef: ownerRef,
isOpen: isOwnerOpen,
toggle: toggleOwner,
handleBlur: handleOwnerBlur,
hide: hideOwner
} = useDropdown();
const onSelectUser = function (newValue: number) { const onSelectUser = function (newValue: number) {
ownerSelector.hide(); hideOwner();
if (newValue === schema.owner) { if (newValue === schema.owner) {
return; return;
} }
@ -103,12 +109,12 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
/> />
</div> </div>
<div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}> <div className='relative' ref={ownerRef} onBlur={handleOwnerBlur}>
<SelectUser <SelectUser
className='absolute -top-2 right-0 w-100 text-sm' className='absolute -top-2 right-0 w-100 text-sm'
value={schema.owner} value={schema.owner}
onChange={user => user && onSelectUser(user)} onChange={user => user && onSelectUser(user)}
hidden={!ownerSelector.isOpen} hidden={!isOwnerOpen}
/> />
<ValueIcon <ValueIcon
@ -116,7 +122,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
icon={<IconOwner size='1.25rem' className='icon-primary' />} icon={<IconOwner size='1.25rem' className='icon-primary' />}
value={getUserLabel(schema.owner)} value={getUserLabel(schema.owner)}
title={isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Владелец'} title={isAttachedToOSS ? 'Владелец наследуется от ОСС' : 'Владелец'}
onClick={ownerSelector.toggle} onClick={toggleOwner}
disabled={isModified || isProcessing || isAttachedToOSS || role < UserRole.OWNER} disabled={isModified || isProcessing || isAttachedToOSS || role < UserRole.OWNER}
/> />
</div> </div>

View File

@ -19,13 +19,19 @@ interface MenuRoleProps {
export function MenuRole({ isOwned, isEditor }: MenuRoleProps) { export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
const { user, isAnonymous } = useAuthSuspense(); const { user, isAnonymous } = useAuthSuspense();
const router = useConceptNavigation(); const router = useConceptNavigation();
const accessMenu = useDropdown(); const {
elementRef: accessMenuRef,
isOpen: isAccessOpen,
toggle: toggleAccess,
handleBlur: handleAccessBlur,
hide: hideAccess
} = useDropdown();
const role = useRoleStore(state => state.role); const role = useRoleStore(state => state.role);
const setRole = useRoleStore(state => state.setRole); const setRole = useRoleStore(state => state.setRole);
function handleChangeMode(newMode: UserRole) { function handleChangeMode(newMode: UserRole) {
accessMenu.hide(); hideAccess();
setRole(newMode); setRole(newMode);
} }
@ -34,7 +40,7 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
<MiniButton <MiniButton
noPadding noPadding
titleHtml='<b>Анонимный режим</b><br />Войти в Портал' titleHtml='<b>Анонимный режим</b><br />Войти в Портал'
hideTitle={accessMenu.isOpen} hideTitle={isAccessOpen}
className='h-full pr-2 pl-3 bg-transparent' className='h-full pr-2 pl-3 bg-transparent'
icon={<IconAlert size='1.25rem' className='icon-red' />} icon={<IconAlert size='1.25rem' className='icon-red' />}
onClick={() => router.push({ path: urls.login })} onClick={() => router.push({ path: urls.login })}
@ -43,17 +49,17 @@ export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
} }
return ( return (
<div ref={accessMenu.ref} onBlur={accessMenu.handleBlur} className='relative'> <div ref={accessMenuRef} onBlur={handleAccessBlur} className='relative'>
<MiniButton <MiniButton
noHover noHover
noPadding noPadding
title={`Режим ${labelUserRole(role)}`} title={`Режим ${labelUserRole(role)}`}
hideTitle={accessMenu.isOpen} hideTitle={isAccessOpen}
className='h-full pr-2 text-muted-foreground hover:text-primary cc-animate-color' className='h-full pr-2 text-muted-foreground hover:text-primary cc-animate-color'
icon={<IconRole value={role} size='1.25rem' className='' />} icon={<IconRole value={role} size='1.25rem' className='' />}
onClick={accessMenu.toggle} onClick={toggleAccess}
/> />
<Dropdown isOpen={accessMenu.isOpen} margin='mt-3'> <Dropdown isOpen={isAccessOpen} margin='mt-3'>
<DropdownButton <DropdownButton
text={labelUserRole(UserRole.READER)} text={labelUserRole(UserRole.READER)}
title={describeUserRole(UserRole.READER)} title={describeUserRole(UserRole.READER)}

View File

@ -17,31 +17,26 @@ interface MiniSelectorOSSProps extends Styling {
} }
export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: MiniSelectorOSSProps) { export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: MiniSelectorOSSProps) {
const ossMenu = useDropdown(); const { elementRef: ossRef, isOpen: isOssOpen, toggle: toggleOss, handleBlur: handleOssBlur } = useDropdown();
function onToggle(event: React.MouseEvent<HTMLElement>) { function onToggle(event: React.MouseEvent<HTMLElement>) {
if (items.length > 1) { if (items.length > 1) {
ossMenu.toggle(); toggleOss();
} else { } else {
onSelect(event, items[0]); onSelect(event, items[0]);
} }
} }
return ( return (
<div <div ref={ossRef} onBlur={handleOssBlur} className={clsx('relative flex items-center', className)} {...restProps}>
ref={ossMenu.ref}
onBlur={ossMenu.handleBlur}
className={clsx('relative flex items-center', className)}
{...restProps}
>
<MiniButton <MiniButton
title='Операционные схемы' title='Операционные схемы'
icon={<IconOSS size='1.25rem' className='icon-primary' />} icon={<IconOSS size='1.25rem' className='icon-primary' />}
hideTitle={ossMenu.isOpen} hideTitle={isOssOpen}
onClick={onToggle} onClick={onToggle}
/> />
{items.length > 1 ? ( {items.length > 1 ? (
<Dropdown isOpen={ossMenu.isOpen} margin='mt-1'> <Dropdown isOpen={isOssOpen} margin='mt-1'>
<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

View File

@ -25,29 +25,29 @@ export function SelectLocationContext({
dropdownHeight = 'h-50', dropdownHeight = 'h-50',
...restProps ...restProps
}: SelectLocationContextProps) { }: SelectLocationContextProps) {
const menu = useDropdown(); const { elementRef, handleBlur, isOpen, toggle, hide } = useDropdown();
function handleClick(event: React.MouseEvent<Element>, newValue: string) { function handleClick(event: React.MouseEvent<Element>, newValue: string) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
menu.hide(); hide();
onChange(newValue); onChange(newValue);
} }
return ( return (
<div <div
ref={menu.ref} // ref={elementRef} //
onBlur={menu.handleBlur} onBlur={handleBlur}
className={clsx('text-right self-start select-none', className)} className={clsx('text-right self-start select-none', className)}
{...restProps} {...restProps}
> >
<MiniButton <MiniButton
title={title} title={title}
hideTitle={menu.isOpen} hideTitle={isOpen}
icon={<IconFolderTree size='1.25rem' className='icon-primary' />} icon={<IconFolderTree size='1.25rem' className='icon-primary' />}
onClick={() => menu.toggle()} onClick={toggle}
/> />
<Dropdown isOpen={menu.isOpen} className={clsx('w-80 z-tooltip', dropdownHeight)}> <Dropdown isOpen={isOpen} className={clsx('w-80 z-tooltip', dropdownHeight)}>
<SelectLocation <SelectLocation
value={value} value={value}
prefix={prefixes.folders_list} prefix={prefixes.folders_list}

View File

@ -45,7 +45,13 @@ export function PickSchema({
...restProps ...restProps
}: PickSchemaProps) { }: PickSchemaProps) {
const intl = useIntl(); const intl = useIntl();
const locationMenu = useDropdown(); const {
elementRef: locationRef,
isOpen: isLocationOpen,
toggle: toggleLocation,
handleBlur: handleLocationBlur,
hide: hideLocationMenu
} = useDropdown();
const [filterText, setFilterText] = useState(initialFilter); const [filterText, setFilterText] = useState(initialFilter);
const [filterLocation, setFilterLocation] = useState(''); const [filterLocation, setFilterLocation] = useState('');
@ -99,7 +105,7 @@ export function PickSchema({
function handleLocationClick(event: React.MouseEvent<Element>, newValue: string) { function handleLocationClick(event: React.MouseEvent<Element>, newValue: string) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
locationMenu.hide(); hideLocationMenu();
setFilterLocation(newValue); setFilterLocation(newValue);
} }
@ -113,14 +119,14 @@ export function PickSchema({
query={filterText} query={filterText}
onChangeQuery={newValue => setFilterText(newValue)} onChangeQuery={newValue => setFilterText(newValue)}
/> />
<div className='relative' ref={locationMenu.ref} onBlur={locationMenu.handleBlur}> <div className='relative' ref={locationRef} onBlur={handleLocationBlur}>
<MiniButton <MiniButton
title='Фильтр по расположению' title='Фильтр по расположению'
icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />} icon={<IconFolderTree size='1.25rem' className={!!filterLocation ? 'icon-green' : 'icon-primary'} />}
className='mt-1' className='mt-1'
onClick={() => locationMenu.toggle()} onClick={toggleLocation}
/> />
<Dropdown isOpen={locationMenu.isOpen} stretchLeft className='w-80 h-50'> <Dropdown isOpen={isLocationOpen} stretchLeft className='w-80 h-50'>
<SelectLocation <SelectLocation
value={filterLocation} value={filterLocation}
prefix={prefixes.folders_list} prefix={prefixes.folders_list}

View File

@ -28,26 +28,26 @@ export function SelectAccessPolicy({
onChange, onChange,
...restProps ...restProps
}: SelectAccessPolicyProps) { }: SelectAccessPolicyProps) {
const menu = useDropdown(); const { elementRef, handleBlur, isOpen, toggle, hide } = useDropdown();
function handleChange(newValue: AccessPolicy) { function handleChange(newValue: AccessPolicy) {
menu.hide(); hide();
if (newValue !== value) { if (newValue !== value) {
onChange(newValue); onChange(newValue);
} }
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className={clsx('relative', className)} {...restProps}> <div ref={elementRef} onBlur={handleBlur} className={clsx('relative', className)} {...restProps}>
<MiniButton <MiniButton
title={`Доступ: ${labelAccessPolicy(value)}`} title={`Доступ: ${labelAccessPolicy(value)}`}
hideTitle={menu.isOpen} hideTitle={isOpen}
className='h-full' className='h-full'
icon={<IconAccessPolicy value={value} size='1.25rem' />} icon={<IconAccessPolicy value={value} size='1.25rem' />}
onClick={menu.toggle} onClick={toggle}
disabled={disabled} disabled={disabled}
/> />
<Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft} margin='mt-1'> <Dropdown isOpen={isOpen} stretchLeft={stretchLeft} margin='mt-1'>
{Object.values(AccessPolicy).map((item, index) => ( {Object.values(AccessPolicy).map((item, index) => (
<DropdownButton <DropdownButton
key={`${prefixes.policy_list}${index}`} key={`${prefixes.policy_list}${index}`}

View File

@ -26,27 +26,27 @@ export function SelectItemType({
onChange, onChange,
...restProps ...restProps
}: SelectItemTypeProps) { }: SelectItemTypeProps) {
const menu = useDropdown(); const { elementRef, handleBlur, isOpen, toggle, hide } = useDropdown();
function handleChange(newValue: LibraryItemType) { function handleChange(newValue: LibraryItemType) {
menu.hide(); hide();
if (newValue !== value) { if (newValue !== value) {
onChange(newValue); onChange(newValue);
} }
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className={cn('relative', className)} {...restProps}> <div ref={elementRef} onBlur={handleBlur} className={cn('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
title={describeLibraryItemType(value)} title={describeLibraryItemType(value)}
hideTitle={menu.isOpen} hideTitle={isOpen}
className='h-full px-2 py-1 rounded-lg' className='h-full px-2 py-1 rounded-lg'
icon={<IconLibraryItemType value={value} size='1.25rem' />} icon={<IconLibraryItemType value={value} size='1.25rem' />}
text={labelLibraryItemType(value)} text={labelLibraryItemType(value)}
onClick={menu.toggle} onClick={toggle}
disabled={disabled} disabled={disabled}
/> />
<Dropdown isOpen={menu.isOpen} stretchLeft={stretchLeft} margin='mt-1'> <Dropdown isOpen={isOpen} stretchLeft={stretchLeft} margin='mt-1'>
{Object.values(LibraryItemType).map((item, index) => ( {Object.values(LibraryItemType).map((item, index) => (
<DropdownButton <DropdownButton
key={`${prefixes.policy_list}${index}`} key={`${prefixes.policy_list}${index}`}

View File

@ -24,31 +24,31 @@ export function SelectLocationHead({
className, className,
...restProps ...restProps
}: SelectLocationHeadProps) { }: SelectLocationHeadProps) {
const menu = useDropdown(); const { elementRef, handleBlur, isOpen, toggle, hide } = useDropdown();
function handleChange(newValue: LocationHead) { function handleChange(newValue: LocationHead) {
menu.hide(); hide();
onChange(newValue); onChange(newValue);
} }
return ( return (
<div <div
ref={menu.ref} // ref={elementRef} //
onBlur={menu.handleBlur} onBlur={handleBlur}
className={cn('text-right relative', className)} className={cn('text-right relative', className)}
{...restProps} {...restProps}
> >
<SelectorButton <SelectorButton
tabIndex={-1} tabIndex={-1}
title={describeLocationHead(value)} title={describeLocationHead(value)}
hideTitle={menu.isOpen} hideTitle={isOpen}
className='h-full' className='h-full'
icon={<IconLocationHead value={value} size='1rem' />} icon={<IconLocationHead value={value} size='1rem' />}
text={labelLocationHead(value)} text={labelLocationHead(value)}
onClick={menu.toggle} onClick={toggle}
/> />
<Dropdown isOpen={menu.isOpen} stretchLeft margin='mt-2'> <Dropdown isOpen={isOpen} stretchLeft margin='mt-2'>
{Object.values(LocationHead) {Object.values(LocationHead)
.filter(head => !excluded.includes(head)) .filter(head => !excluded.includes(head))
.map((head, index) => { .map((head, index) => {

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useMemo, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { MiniButton } from '@/components/control'; import { MiniButton } from '@/components/control';
@ -24,25 +24,43 @@ export function SelectLocation({ value, dense, prefix, onClick, className, style
const { folders } = useFolders(); const { folders } = useFolders();
const activeNode = folders.at(value); const activeNode = folders.at(value);
const items = folders.getTree(); const items = folders.getTree();
const [folded, setFolded] = useState<FolderNode[]>(items); const baseFolded = useMemo(
() => items.filter(item => item !== activeNode && !activeNode?.hasPredecessor(item)),
[items, activeNode]
);
useEffect(() => { // Manual overrides: true => force folded, false => force unfolded
setFolded(items.filter(item => item !== activeNode && !activeNode?.hasPredecessor(item))); const [manualOverrides, setManualOverrides] = useState<Map<FolderNode, boolean>>(new Map());
}, [items, activeNode]);
const folded = useMemo(() => {
const set = new Set<FolderNode>(baseFolded);
manualOverrides.forEach((isFolded, node) => {
if (isFolded) {
set.add(node);
} else {
set.delete(node);
}
});
return Array.from(set);
}, [baseFolded, manualOverrides]);
function onFoldItem(target: FolderNode, showChildren: boolean) { function onFoldItem(target: FolderNode, showChildren: boolean) {
setFolded(prev => setManualOverrides(prev => {
items.filter(item => { const next = new Map(prev);
if (item === target) { if (showChildren) {
return !showChildren; // Currently folded -> unfold target only
} next.set(target, false);
if (!showChildren && item.hasPredecessor(target)) {
return true;
} else { } else {
return prev.includes(item); // Currently unfolded -> fold target and all its descendants
next.set(target, true);
for (const item of items) {
if (item !== target && item.hasPredecessor(target)) {
next.set(item, true);
} }
}) }
); }
return next;
});
} }
function handleClickFold(event: React.MouseEvent<Element>, target: FolderNode, showChildren: boolean) { function handleClickFold(event: React.MouseEvent<Element>, target: FolderNode, showChildren: boolean) {

View File

@ -24,7 +24,12 @@ interface ToolbarSearchProps {
export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps) { export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps) {
const { items } = useLibrarySuspense(); const { items } = useLibrarySuspense();
const userMenu = useDropdown(); const {
elementRef: userElementRef,
handleBlur: userHandleBlur,
isOpen: isUserOpen,
toggle: toggleUser
} = useDropdown();
const query = useLibrarySearchStore(state => state.query); const query = useLibrarySearchStore(state => state.query);
const setQuery = useLibrarySearchStore(state => state.setQuery); const setQuery = useLibrarySearchStore(state => state.setQuery);
@ -70,14 +75,14 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
onClick={toggleVisible} onClick={toggleVisible}
/> />
<div ref={userMenu.ref} onBlur={userMenu.handleBlur} className='relative flex'> <div ref={userElementRef} onBlur={userHandleBlur} className='relative flex'>
<MiniButton <MiniButton
title='Поиск пользователя' title='Поиск пользователя'
hideTitle={userMenu.isOpen} hideTitle={isUserOpen}
icon={<IconUserSearch size='1.25rem' className={userActive ? 'icon-green' : 'icon-primary'} />} icon={<IconUserSearch size='1.25rem' className={userActive ? 'icon-green' : 'icon-primary'} />}
onClick={userMenu.toggle} onClick={toggleUser}
/> />
<Dropdown isOpen={userMenu.isOpen} margin='mt-1'> <Dropdown isOpen={isUserOpen} margin='mt-1'>
<DropdownButton <DropdownButton
text='Я - Владелец' text='Я - Владелец'
title='Фильтровать схемы, в которых текущий пользователь является владельцем' title='Фильтровать схемы, в которых текущий пользователь является владельцем'

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler 'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client'; 'use client';
import { useRef } from 'react'; import { useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -48,11 +48,9 @@ export function FormOSS() {
const visible = useWatch({ control, name: 'visible' }); const visible = useWatch({ control, name: 'visible' });
const readOnly = useWatch({ control, name: 'read_only' }); const readOnly = useWatch({ control, name: 'read_only' });
const prevDirty = useRef(isDirty); useEffect(() => {
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty); setIsModified(isDirty);
} }, [isDirty, setIsModified]);
function onSubmit(data: IUpdateLibraryItemDTO) { function onSubmit(data: IUpdateLibraryItemDTO) {
return updateOss(data).then(() => reset({ ...data })); return updateOss(data).then(() => reset({ ...data }));

View File

@ -104,10 +104,13 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => {
setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.refreshTimeout); setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.refreshTimeout);
} }
if ( useEffect(() => {
viewportInitialized && if (!viewportInitialized) return;
(prevSelected.current.length !== selected.length || prevSelected.current.some((id, i) => id !== selected[i])) const hasChanged =
) { prevSelected.current.length !== selected.length || prevSelected.current.some((id, i) => id !== selected[i]);
if (!hasChanged) return;
prevSelected.current = selected; prevSelected.current = selected;
setNodes(prev => setNodes(prev =>
prev.map(node => ({ prev.map(node => ({
@ -115,7 +118,7 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => {
selected: selected.includes(node.id) selected: selected.includes(node.id)
})) }))
); );
} }, [viewportInitialized, selected, setNodes]);
return ( return (
<OssFlowContext <OssFlowContext

View File

@ -52,7 +52,7 @@ export function ToolbarSchema({
isMutable, isMutable,
className className
}: ToolbarSchemaProps) { }: ToolbarSchemaProps) {
const menuSchema = useDropdown(); const { elementRef, isOpen, handleBlur, toggle, hide } = useDropdown();
const router = useConceptNavigation(); const router = useConceptNavigation();
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
const searchText = useCstSearchStore(state => state.query); const searchText = useCstSearchStore(state => state.query);
@ -196,25 +196,25 @@ export function ToolbarSchema({
} }
function handleReindex() { function handleReindex() {
menuSchema.hide(); hide();
void resetAliases({ itemID: schema.id }); void resetAliases({ itemID: schema.id });
} }
function handleRestoreOrder() { function handleRestoreOrder() {
menuSchema.hide(); hide();
void restoreOrder({ itemID: schema.id }); void restoreOrder({ itemID: schema.id });
} }
return ( return (
<div className={cn('flex gap-0.5', className)}> <div className={cn('flex gap-0.5', className)}>
<div ref={menuSchema.ref} onBlur={menuSchema.handleBlur} className='flex relative items-center'> <div ref={elementRef} onBlur={handleBlur} className='flex relative items-center'>
<MiniButton <MiniButton
title='Редактирование концептуальной схемы' title='Редактирование концептуальной схемы'
hideTitle={menuSchema.isOpen} hideTitle={isOpen}
icon={<IconRSForm size='1rem' className='icon-primary' />} icon={<IconRSForm size='1rem' className='icon-primary' />}
onClick={menuSchema.toggle} onClick={toggle}
/> />
<Dropdown isOpen={menuSchema.isOpen} margin='mt-0.5'> <Dropdown isOpen={isOpen} margin='mt-0.5'>
<DropdownButton <DropdownButton
text='Упорядочить список' text='Упорядочить список'
titleHtml='Упорядочить список, исходя из <br/>логики типов и связей конституент' titleHtml='Упорядочить список, исходя из <br/>логики типов и связей конституент'

View File

@ -71,7 +71,7 @@ export function ToolbarOssGraph({
const getLayout = useGetLayout(); const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout(); const { updateLayout } = useUpdateLayout();
const { user } = useAuthSuspense(); const { user } = useAuthSuspense();
const menu = useDropdown(); const { elementRef: menuRef, isOpen: isMenuOpen, toggle: toggleMenu, handleBlur: handleMenuBlur } = useDropdown();
const showOptions = useDialogsStore(state => state.showOssOptions); const showOptions = useDialogsStore(state => state.showOssOptions);
const showSidePanel = usePreferencesStore(state => state.showOssSidePanel); const showSidePanel = usePreferencesStore(state => state.showOssSidePanel);
@ -84,7 +84,7 @@ export function ToolbarOssGraph({
function handleMenuToggle() { function handleMenuToggle() {
hideContextMenu(); hideContextMenu();
menu.toggle(); toggleMenu();
} }
function handleShowOptions() { function handleShowOptions() {
@ -147,7 +147,7 @@ export function ToolbarOssGraph({
<MiniButton <MiniButton
aria-label='Сохранить изменения' aria-label='Сохранить изменения'
titleHtml={prepareTooltip('Сохранить изменения', isMac() ? '⌘ + S' : 'Ctrl + S')} titleHtml={prepareTooltip('Сохранить изменения', isMac() ? '⌘ + S' : 'Ctrl + S')}
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
icon={<IconSave size='1.25rem' className='icon-primary' />} icon={<IconSave size='1.25rem' className='icon-primary' />}
onClick={handleSavePositions} onClick={handleSavePositions}
disabled={isProcessing} disabled={isProcessing}
@ -155,20 +155,20 @@ export function ToolbarOssGraph({
<MiniButton <MiniButton
aria-label='Редактировать выбранную' aria-label='Редактировать выбранную'
titleHtml={prepareTooltip('Редактировать выбранную', isIOS() ? '' : 'Правый клик')} titleHtml={prepareTooltip('Редактировать выбранную', isIOS() ? '' : 'Правый клик')}
hideTitle={isContextMenuOpen || menu.isOpen} hideTitle={isContextMenuOpen || isMenuOpen}
icon={<IconEdit size='1.25rem' className='icon-primary' />} icon={<IconEdit size='1.25rem' className='icon-primary' />}
onClick={handleEditItem} onClick={handleEditItem}
disabled={selectedItems.length !== 1 || isProcessing} disabled={selectedItems.length !== 1 || isProcessing}
/> />
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'> <div ref={menuRef} onBlur={handleMenuBlur} className='relative'>
<MiniButton <MiniButton
title='Добавить...' title='Добавить...'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
icon={<IconNewItem size='1.25rem' className='icon-green' />} icon={<IconNewItem size='1.25rem' className='icon-green' />}
onClick={handleMenuToggle} onClick={handleMenuToggle}
disabled={isProcessing} disabled={isProcessing}
/> />
<Dropdown isOpen={menu.isOpen} className='-translate-x-1/2'> <Dropdown isOpen={isMenuOpen} className='-translate-x-1/2'>
<DropdownButton <DropdownButton
text='Новый блок' text='Новый блок'
titleHtml={prepareTooltip('Новый блок', 'Alt + 1')} titleHtml={prepareTooltip('Новый блок', 'Alt + 1')}
@ -216,7 +216,7 @@ export function ToolbarOssGraph({
<MiniButton <MiniButton
aria-label='Удалить выбранную' aria-label='Удалить выбранную'
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
onClick={onDelete} onClick={onDelete}
disabled={ disabled={

View File

@ -26,15 +26,21 @@ export function MenuMain() {
const showQR = useDialogsStore(state => state.showQR); const showQR = useDialogsStore(state => state.showQR);
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
function handleDelete() { function handleDelete() {
menu.hide(); hideMenu();
deleteSchema(); deleteSchema();
} }
function handleShare() { function handleShare() {
menu.hide(); hideMenu();
sharePage(); sharePage();
} }
@ -43,22 +49,22 @@ export function MenuMain() {
} }
function handleShowQR() { function handleShowQR() {
menu.hide(); hideMenu();
showQR({ target: generatePageQR() }); showQR({ target: generatePageQR() });
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'> <div ref={menuRef} onBlur={handleMenuBlur} className='relative'>
<MiniButton <MiniButton
noHover noHover
noPadding noPadding
title='Меню' title='Меню'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
icon={<IconMenu size='1.25rem' />} icon={<IconMenu size='1.25rem' />}
className='h-full px-2 text-muted-foreground hover:text-primary cc-animate-color' className='h-full px-2 text-muted-foreground hover:text-primary cc-animate-color'
onClick={menu.toggle} onClick={toggleMenu}
/> />
<Dropdown isOpen={menu.isOpen} margin='mt-3'> <Dropdown isOpen={isMenuOpen} margin='mt-3'>
<DropdownButton <DropdownButton
text='Поделиться' text='Поделиться'
title='Скопировать ссылку в буфер обмена' title='Скопировать ссылку в буфер обмена'

View File

@ -38,8 +38,20 @@ export function ToolbarGraphSelection({
onChange, onChange,
...restProps ...restProps
}: ToolbarGraphSelectionProps) { }: ToolbarGraphSelectionProps) {
const selectedMenu = useDropdown(); const {
const groupMenu = useDropdown(); elementRef: selectedElementRef,
handleBlur: selectedHandleBlur,
isOpen: isSelectedOpen,
toggle: toggleSelected,
hide: hideSelected
} = useDropdown();
const {
elementRef: groupElementRef,
handleBlur: groupHandleBlur,
isOpen: isGroupOpen,
toggle: toggleGroup,
hide: hideGroup
} = useDropdown();
const emptySelection = value.length === 0; const emptySelection = value.length === 0;
function handleSelectReset() { function handleSelectReset() {
@ -47,23 +59,23 @@ export function ToolbarGraphSelection({
} }
function handleSelectCore() { function handleSelectCore() {
groupMenu.hide(); hideGroup();
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() {
groupMenu.hide(); hideGroup();
onChange([...graph.nodes.keys()].filter((item: number) => !isInherited(item))); onChange([...graph.nodes.keys()].filter((item: number) => !isInherited(item)));
} }
function handleSelectInherited() { function handleSelectInherited() {
groupMenu.hide(); hideGroup();
onChange([...graph.nodes.keys()].filter(isInherited)); onChange([...graph.nodes.keys()].filter(isInherited));
} }
function handleSelectCrucial() { function handleSelectCrucial() {
groupMenu.hide(); hideGroup();
onChange([...graph.nodes.keys()].filter(isCrucial)); onChange([...graph.nodes.keys()].filter(isCrucial));
} }
@ -76,22 +88,22 @@ export function ToolbarGraphSelection({
} }
function handleSelectMaximize() { function handleSelectMaximize() {
selectedMenu.hide(); hideSelected();
onChange(graph.maximizePart(value)); onChange(graph.maximizePart(value));
} }
function handleSelectInvert() { function handleSelectInvert() {
selectedMenu.hide(); hideSelected();
onChange([...graph.nodes.keys()].filter(item => !value.includes(item))); onChange([...graph.nodes.keys()].filter(item => !value.includes(item)));
} }
function handleSelectAllInputs() { function handleSelectAllInputs() {
selectedMenu.hide(); hideSelected();
onChange([...value, ...graph.expandAllInputs(value)]); onChange([...value, ...graph.expandAllInputs(value)]);
} }
function handleSelectAllOutputs() { function handleSelectAllOutputs() {
selectedMenu.hide(); hideSelected();
onChange([...value, ...graph.expandAllOutputs(value)]); onChange([...value, ...graph.expandAllOutputs(value)]);
} }
@ -104,15 +116,15 @@ export function ToolbarGraphSelection({
disabled={emptySelection} disabled={emptySelection}
/> />
<div ref={selectedMenu.ref} onBlur={selectedMenu.handleBlur} className='flex items-center relative'> <div ref={selectedElementRef} onBlur={selectedHandleBlur} className='flex items-center relative'>
<MiniButton <MiniButton
title='Выделить на основе выбранных...' title='Выделить на основе выбранных...'
hideTitle={selectedMenu.isOpen} hideTitle={isSelectedOpen}
icon={<IconContextSelection size='1.25rem' className='icon-primary' />} icon={<IconContextSelection size='1.25rem' className='icon-primary' />}
onClick={selectedMenu.toggle} onClick={toggleSelected}
disabled={emptySelection} disabled={emptySelection}
/> />
<Dropdown isOpen={selectedMenu.isOpen} className='-translate-x-1/2'> <Dropdown isOpen={isSelectedOpen} className='-translate-x-1/2'>
<DropdownButton <DropdownButton
text='Поставщики' text='Поставщики'
title='Выделить поставщиков' title='Выделить поставщиков'
@ -159,14 +171,14 @@ export function ToolbarGraphSelection({
</Dropdown> </Dropdown>
</div> </div>
<div ref={groupMenu.ref} onBlur={groupMenu.handleBlur} className='flex items-center relative'> <div ref={groupElementRef} onBlur={groupHandleBlur} className='flex items-center relative'>
<MiniButton <MiniButton
title='Выделить группу...' title='Выделить группу...'
hideTitle={groupMenu.isOpen} hideTitle={isGroupOpen}
icon={<IconGroupSelection size='1.25rem' className='icon-primary' />} icon={<IconGroupSelection size='1.25rem' className='icon-primary' />}
onClick={groupMenu.toggle} onClick={toggleGroup}
/> />
<Dropdown isOpen={groupMenu.isOpen} stretchLeft> <Dropdown isOpen={isGroupOpen} stretchLeft>
<DropdownButton <DropdownButton
text='ядро' text='ядро'
title='Выделить ядро' title='Выделить ядро'

View File

@ -17,25 +17,31 @@ interface SelectGraphFilterProps extends Styling {
} }
export function SelectGraphFilter({ value, dense, className, onChange, ...restProps }: SelectGraphFilterProps) { export function SelectGraphFilter({ value, dense, className, onChange, ...restProps }: SelectGraphFilterProps) {
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
function handleChange(newValue: DependencyMode) { function handleChange(newValue: DependencyMode) {
menu.hide(); hideMenu();
onChange(newValue); onChange(newValue);
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className={cn('relative', className)} {...restProps}> <div ref={menuRef} onBlur={handleMenuBlur} className={cn('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
tabIndex={-1} tabIndex={-1}
titleHtml='Настройка фильтрации <br/>по графу термов' titleHtml='Настройка фильтрации <br/>по графу термов'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
className='h-full pr-2' className='h-full pr-2'
icon={<IconDependencyMode value={value} size='1rem' />} icon={<IconDependencyMode value={value} size='1rem' />}
text={!dense ? labelCstSource(value) : undefined} text={!dense ? labelCstSource(value) : undefined}
onClick={menu.toggle} onClick={toggleMenu}
/> />
<Dropdown stretchLeft isOpen={menu.isOpen} margin='mt-3'> <Dropdown stretchLeft isOpen={isMenuOpen} margin='mt-3'>
{Object.values(DependencyMode).map((value, index) => { {Object.values(DependencyMode).map((value, index) => {
const source = value as DependencyMode; const source = value as DependencyMode;
return ( return (

View File

@ -17,24 +17,30 @@ interface SelectMatchModeProps extends Styling {
} }
export function SelectMatchMode({ value, dense, className, onChange, ...restProps }: SelectMatchModeProps) { export function SelectMatchMode({ value, dense, className, onChange, ...restProps }: SelectMatchModeProps) {
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
function handleChange(newValue: CstMatchMode) { function handleChange(newValue: CstMatchMode) {
menu.hide(); hideMenu();
onChange(newValue); onChange(newValue);
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className={cn('relative', className)} {...restProps}> <div ref={menuRef} onBlur={handleMenuBlur} className={cn('relative', className)} {...restProps}>
<SelectorButton <SelectorButton
titleHtml='Настройка фильтрации <br/>по проверяемым атрибутам' titleHtml='Настройка фильтрации <br/>по проверяемым атрибутам'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
className='h-full pr-2' className='h-full pr-2'
icon={<IconCstMatchMode value={value} size='1rem' />} icon={<IconCstMatchMode value={value} size='1rem' />}
text={!dense ? labelCstMatchMode(value) : undefined} text={!dense ? labelCstMatchMode(value) : undefined}
onClick={menu.toggle} onClick={toggleMenu}
/> />
<Dropdown stretchLeft isOpen={menu.isOpen} margin='mt-3'> <Dropdown stretchLeft isOpen={isMenuOpen} margin='mt-3'>
{Object.values(CstMatchMode).map((value, index) => { {Object.values(CstMatchMode).map((value, index) => {
const matchMode = value as CstMatchMode; const matchMode = value as CstMatchMode;
return ( return (

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useEffect, useRef } from 'react';
import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/data-table'; import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/data-table';
import { NoData, TextContent } from '@/components/view'; import { NoData, TextContent } from '@/components/view';
@ -37,6 +37,8 @@ export function TableSideConstituents({
const items = useFilteredItems(schema, activeCst); const items = useFilteredItems(schema, activeCst);
const prevActiveCstID = useRef<number | null>(null); const prevActiveCstID = useRef<number | null>(null);
useEffect(() => {
if (autoScroll && prevActiveCstID.current !== activeCst?.id) { if (autoScroll && prevActiveCstID.current !== activeCst?.id) {
prevActiveCstID.current = activeCst?.id ?? null; prevActiveCstID.current = activeCst?.id ?? null;
if (!!activeCst) { if (!!activeCst) {
@ -52,6 +54,7 @@ export function TableSideConstituents({
}, PARAMETER.refreshTimeout); }, PARAMETER.refreshTimeout);
} }
} }
}, [autoScroll, activeCst]);
const columns = [ const columns = [
columnHelper.accessor('alias', { columnHelper.accessor('alias', {

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler 'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client'; 'use client';
import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; 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';
@ -121,17 +121,9 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
const isElementary = isBaseSet(activeCst.cst_type); const isElementary = isBaseSet(activeCst.cst_type);
const showConvention = !!activeCst.convention || forceComment || isBasic; const showConvention = !!activeCst.convention || forceComment || isBasic;
const prevActiveCstID = useRef(activeCst.id); useLayoutEffect(() => setIsModified(false), [activeCst.id, setIsModified]);
const prevToggleReset = useRef(toggleReset);
const prevSchema = useRef(schema); useEffect(() => {
if (
prevActiveCstID.current !== activeCst.id ||
prevToggleReset.current !== toggleReset ||
prevSchema.current !== schema
) {
prevActiveCstID.current = activeCst.id;
prevToggleReset.current = toggleReset;
prevSchema.current = schema;
reset({ reset({
target: activeCst.id, target: activeCst.id,
item_data: { item_data: {
@ -141,17 +133,30 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
definition_formal: activeCst.definition_formal definition_formal: activeCst.definition_formal
} }
}); });
}, [
activeCst.id,
activeCst.convention,
activeCst.term_raw,
activeCst.definition_raw,
activeCst.definition_formal,
toggleReset,
schema,
reset
]);
useEffect(() => {
// TODO: suspect this is too complex solution
const timeoutId = setTimeout(() => {
setForceComment(false); setForceComment(false);
setLocalParse(null); setLocalParse(null);
} }, 0);
useLayoutEffect(() => setIsModified(false), [activeCst.id, setIsModified]); return () => clearTimeout(timeoutId);
}, [activeCst.id, toggleReset, schema]);
const prevDirty = useRef(isDirty); useEffect(() => {
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty); setIsModified(isDirty);
} }, [isDirty, setIsModified]);
function onSubmit(data: IUpdateConstituentaDTO) { function onSubmit(data: IUpdateConstituentaDTO) {
void updateConstituenta({ itemID: schema.id, data }).then(() => { void updateConstituenta({ itemID: schema.id, data }).then(() => {

View File

@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler 'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client'; 'use client';
import { useRef } from 'react'; import { useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -57,9 +57,7 @@ export function FormRSForm() {
const visible = useWatch({ control, name: 'visible' }); const visible = useWatch({ control, name: 'visible' });
const readOnly = useWatch({ control, name: 'read_only' }); const readOnly = useWatch({ control, name: 'read_only' });
const prevSchema = useRef(schema); useEffect(() => {
if (prevSchema.current !== schema) {
prevSchema.current = schema;
reset({ reset({
id: schema.id, id: schema.id,
item_type: LibraryItemType.RSFORM, item_type: LibraryItemType.RSFORM,
@ -69,13 +67,11 @@ export function FormRSForm() {
visible: schema.visible, visible: schema.visible,
read_only: schema.read_only read_only: schema.read_only
}); });
} }, [schema, reset]);
const prevDirty = useRef(isDirty); useEffect(() => {
if (prevDirty.current !== isDirty) {
prevDirty.current = isDirty;
setIsModified(isDirty); setIsModified(isDirty);
} }, [isDirty, setIsModified]);
function handleSelectVersion(version: CurrentVersion) { function handleSelectVersion(version: CurrentVersion) {
router.push({ path: urls.schema(schema.id, version === 'latest' ? undefined : version) }); router.push({ path: urls.schema(schema.id, version === 'latest' ? undefined : version) });

View File

@ -34,7 +34,7 @@ interface ToolbarRSListProps {
export function ToolbarRSList({ className }: ToolbarRSListProps) { export function ToolbarRSList({ className }: ToolbarRSListProps) {
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
const { updateCrucial } = useUpdateCrucial(); const { updateCrucial } = useUpdateCrucial();
const menu = useDropdown(); const { elementRef: menuRef, isOpen: isMenuOpen, toggle: toggleMenu, handleBlur: handleMenuBlur } = useDropdown();
const { const {
schema, schema,
selected, selected,
@ -99,15 +99,15 @@ export function ToolbarRSList({ className }: ToolbarRSListProps) {
onClick={handleToggleCrucial} onClick={handleToggleCrucial}
disabled={isProcessing || selected.length === 0} disabled={isProcessing || selected.length === 0}
/> />
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'> <div ref={menuRef} onBlur={handleMenuBlur} className='relative'>
<MiniButton <MiniButton
title='Добавить пустую конституенту' title='Добавить пустую конституенту'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
icon={<IconOpenList size='1.25rem' className='icon-green' />} icon={<IconOpenList size='1.25rem' className='icon-green' />}
onClick={menu.toggle} onClick={toggleMenu}
disabled={isProcessing} disabled={isProcessing}
/> />
<Dropdown isOpen={menu.isOpen} className='-translate-x-1/2'> <Dropdown isOpen={isMenuOpen} className='-translate-x-1/2'>
{Object.values(CstType).map(typeStr => ( {Object.values(CstType).map(typeStr => (
<DropdownButton <DropdownButton
key={`${prefixes.csttype_list}${typeStr}`} key={`${prefixes.csttype_list}${typeStr}`}

View File

@ -128,10 +128,14 @@ export function TGFlow() {
]); ]);
const prevSelected = useRef<number[]>([]); const prevSelected = useRef<number[]>([]);
if (
viewportInitialized && useEffect(() => {
(prevSelected.current.length !== selected.length || prevSelected.current.some((id, i) => id !== selected[i])) if (!viewportInitialized) return;
) { const hasChanged =
prevSelected.current.length !== selected.length || prevSelected.current.some((id, i) => id !== selected[i]);
if (!hasChanged) return;
prevSelected.current = selected; prevSelected.current = selected;
setNodes(prev => setNodes(prev =>
prev.map(node => ({ prev.map(node => ({
@ -139,7 +143,7 @@ export function TGFlow() {
selected: selected.includes(Number(node.id)) selected: selected.includes(Number(node.id))
})) }))
); );
} }, [viewportInitialized, selected, setNodes]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (isProcessing) { if (isProcessing) {

View File

@ -33,7 +33,13 @@ export function MenuEditSchema() {
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const isModified = useModificationStore(state => state.isModified); const isModified = useModificationStore(state => state.isModified);
const router = useConceptNavigation(); const router = useConceptNavigation();
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
const { schema, activeCst, setSelected, isArchive, isContentEditable, promptTemplate, deselectAll } = useRSEdit(); const { schema, activeCst, setSelected, isArchive, isContentEditable, promptTemplate, deselectAll } = useRSEdit();
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
@ -45,17 +51,17 @@ export function MenuEditSchema() {
const showSubstituteCst = useDialogsStore(state => state.showSubstituteCst); const showSubstituteCst = useDialogsStore(state => state.showSubstituteCst);
function handleReindex() { function handleReindex() {
menu.hide(); hideMenu();
void resetAliases({ itemID: schema.id }); void resetAliases({ itemID: schema.id });
} }
function handleRestoreOrder() { function handleRestoreOrder() {
menu.hide(); hideMenu();
void restoreOrder({ itemID: schema.id }); void restoreOrder({ itemID: schema.id });
} }
function handleSubstituteCst() { function handleSubstituteCst() {
menu.hide(); hideMenu();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -66,12 +72,12 @@ export function MenuEditSchema() {
} }
function handleTemplates() { function handleTemplates() {
menu.hide(); hideMenu();
promptTemplate(); promptTemplate();
} }
function handleProduceStructure(targetCst: IConstituenta | null) { function handleProduceStructure(targetCst: IConstituenta | null) {
menu.hide(); hideMenu();
if (!targetCst) { if (!targetCst) {
return; return;
} }
@ -89,7 +95,7 @@ export function MenuEditSchema() {
} }
function handleInlineSynthesis() { function handleInlineSynthesis() {
menu.hide(); hideMenu();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -108,7 +114,7 @@ export function MenuEditSchema() {
<MiniButton <MiniButton
noPadding noPadding
titleHtml='<b>Архив</b>: Редактирование запрещено<br />Перейти к актуальной версии' titleHtml='<b>Архив</b>: Редактирование запрещено<br />Перейти к актуальной версии'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
className='h-full px-3 bg-transparent' className='h-full px-3 bg-transparent'
icon={<IconArchive size='1.25rem' className='icon-primary' />} icon={<IconArchive size='1.25rem' className='icon-primary' />}
onClick={event => router.push({ path: urls.schema(schema.id), newTab: event.ctrlKey || event.metaKey })} onClick={event => router.push({ path: urls.schema(schema.id), newTab: event.ctrlKey || event.metaKey })}
@ -117,17 +123,17 @@ export function MenuEditSchema() {
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'> <div ref={menuRef} onBlur={handleMenuBlur} className='relative'>
<MiniButton <MiniButton
noHover noHover
noPadding noPadding
title='Редактирование' title='Редактирование'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
className='h-full px-3 text-muted-foreground hover:text-primary cc-animate-color' className='h-full px-3 text-muted-foreground hover:text-primary cc-animate-color'
icon={<IconEdit2 size='1.25rem' />} icon={<IconEdit2 size='1.25rem' />}
onClick={menu.toggle} onClick={toggleMenu}
/> />
<Dropdown isOpen={menu.isOpen} margin='mt-3'> <Dropdown isOpen={isMenuOpen} margin='mt-3'>
<DropdownButton <DropdownButton
text='Шаблоны' text='Шаблоны'
title='Создать конституенту из шаблона' title='Создать конституенту из шаблона'

View File

@ -50,7 +50,13 @@ export function MenuMain() {
const showClone = useDialogsStore(state => state.showCloneLibraryItem); const showClone = useDialogsStore(state => state.showCloneLibraryItem);
const showUpload = useDialogsStore(state => state.showUploadRSForm); const showUpload = useDialogsStore(state => state.showUploadRSForm);
const menu = useDropdown(); const {
elementRef: menuRef,
isOpen: isMenuOpen,
toggle: toggleMenu,
handleBlur: handleMenuBlur,
hide: hideMenu
} = useDropdown();
function calculateCloneLocation() { function calculateCloneLocation() {
const location = schema.location; const location = schema.location;
@ -65,12 +71,12 @@ export function MenuMain() {
} }
function handleDelete() { function handleDelete() {
menu.hide(); hideMenu();
deleteSchema(); deleteSchema();
} }
function handleDownload() { function handleDownload() {
menu.hide(); hideMenu();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -88,12 +94,12 @@ export function MenuMain() {
} }
function handleUpload() { function handleUpload() {
menu.hide(); hideMenu();
showUpload({ itemID: schema.id }); showUpload({ itemID: schema.id });
} }
function handleClone() { function handleClone() {
menu.hide(); hideMenu();
if (isModified && !promptUnsaved()) { if (isModified && !promptUnsaved()) {
return; return;
} }
@ -106,27 +112,27 @@ export function MenuMain() {
} }
function handleShare() { function handleShare() {
menu.hide(); hideMenu();
sharePage(); sharePage();
} }
function handleShowQR() { function handleShowQR() {
menu.hide(); hideMenu();
showQR({ target: generatePageQR() }); showQR({ target: generatePageQR() });
} }
return ( return (
<div ref={menu.ref} onBlur={menu.handleBlur} className='relative'> <div ref={menuRef} onBlur={handleMenuBlur} className='relative'>
<MiniButton <MiniButton
noHover noHover
noPadding noPadding
title='Меню' title='Меню'
hideTitle={menu.isOpen} hideTitle={isMenuOpen}
icon={<IconMenu size='1.25rem' />} icon={<IconMenu size='1.25rem' />}
className='h-full pl-2 text-muted-foreground hover:text-primary cc-animate-color bg-transparent' className='h-full pl-2 text-muted-foreground hover:text-primary cc-animate-color bg-transparent'
onClick={menu.toggle} onClick={toggleMenu}
/> />
<Dropdown isOpen={menu.isOpen} margin='mt-3'> <Dropdown isOpen={isMenuOpen} margin='mt-3'>
<DropdownButton <DropdownButton
text='Поделиться' text='Поделиться'
titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)} titleHtml={tooltipText.shareItem(schema.access_policy === AccessPolicy.PUBLIC)}

View File

@ -1,4 +1,4 @@
import { useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useRoleStore } from './role'; import { useRoleStore } from './role';
@ -13,9 +13,11 @@ export function useAdjustRole(input: AdjustRoleProps) {
const adjustRole = useRoleStore(state => state.adjustRole); const adjustRole = useRoleStore(state => state.adjustRole);
const lastInput = useRef<string | null>(null); const lastInput = useRef<string | null>(null);
useEffect(() => {
const serializedInput = JSON.stringify(input); const serializedInput = JSON.stringify(input);
if (lastInput.current !== serializedInput) { if (lastInput.current !== serializedInput) {
lastInput.current = serializedInput; lastInput.current = serializedInput;
adjustRole(input); adjustRole(input);
} }
}, [input, adjustRole]);
} }

View File

@ -1,10 +1,8 @@
import { useRef } from 'react'; import { useEffect, useMemo } from 'react';
export function useResetOnChange<T>(deps: T[], resetFn: () => void) { export function useResetOnChange<T>(deps: T[], resetFn: () => void) {
const lastDeps = useRef<string | null>(null); const depsKey = useMemo(() => JSON.stringify(deps), [deps]);
const currentDeps = JSON.stringify(deps); useEffect(() => {
if (lastDeps.current !== currentDeps) {
lastDeps.current = currentDeps;
resetFn(); resetFn();
} }, [depsKey, resetFn]);
} }

View File

@ -19,17 +19,13 @@ export function useTransitionTracker(delay: number = DEFAULT_DEBOUNCE_DELAY): bo
if (navigation.location) { if (navigation.location) {
timeout = setTimeout(() => setShowPending(true), delay); timeout = setTimeout(() => setShowPending(true), delay);
} else {
setShowPending(false);
if (timeout) {
clearTimeout(timeout);
}
} }
return () => { return () => {
if (timeout) { if (timeout) {
clearTimeout(timeout); clearTimeout(timeout);
} }
setShowPending(false);
}; };
}, [navigation.location, delay]); }, [navigation.location, delay]);