Compare commits

...

25 Commits

Author SHA1 Message Date
Ivan
238a22b42f R: Update inline packages
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
2025-02-26 23:49:34 +03:00
Ivan
8c1cde76fe npm update 2025-02-26 23:00:55 +03:00
Ivan
87e7b36a95 M: replace deprecated API 2025-02-26 22:23:19 +03:00
Ivan
3351ebc637 B: Fix validation after setValue 2025-02-26 22:10:18 +03:00
Ivan
41aa6106da R: Refactor graph rendering and selection update 2025-02-26 20:46:33 +03:00
Ivan
a470a6c475 M: Refactor tooltips loading 2025-02-26 20:34:58 +03:00
Ivan
efbeb2a343 M: Improve notification delays 2025-02-26 16:46:04 +03:00
Ivan
bade714fbf F: Improve navigation API. Implement async versions 2025-02-26 16:28:16 +03:00
Ivan
1911b04094 Update OssFlow.tsx 2025-02-26 13:48:21 +03:00
Ivan
cab9ae8efc F: Improve OSS graph interactions 2025-02-26 12:54:51 +03:00
Ivan
ba11c1f82b R: Refactor feature dependencies 2025-02-26 00:16:22 +03:00
Ivan
be2ea32674 M: Fix selection styling 2025-02-26 00:10:23 +03:00
Ivan
957313dd43 F: Remove unusable save to image feature 2025-02-25 22:14:45 +03:00
Ivan
091182cf5f R: Reowk TGFlow rerenders 2025-02-25 21:48:22 +03:00
Ivan
90d35484ad B: Fix z-index 2025-02-25 13:24:06 +03:00
Ivan
542b137622 F: Simplify termgraph tooltip 2025-02-25 13:19:07 +03:00
Ivan
63160fe537 Update colors.ts 2025-02-25 13:13:39 +03:00
Ivan
dd79312056 F: Simplify TGFlow tooltips 2025-02-25 12:42:00 +03:00
Ivan
75106508e3 npm update 2025-02-23 16:55:48 +03:00
Ivan
1b1b287004 R: Refactor menu bars and fix QR dialog styling 2025-02-23 16:53:23 +03:00
Ivan
c5238bf1a0 F: Rework users API 2025-02-22 19:21:10 +03:00
Ivan
21269a1072 R: Redistribute constant and global dependencies 2025-02-22 18:39:24 +03:00
Ivan
9dcba3c586 R: Use line-clamp for line limitation 2025-02-22 18:02:31 +03:00
Ivan
b6b57b8b1e B: Fix edit operation form 2025-02-22 17:25:40 +03:00
Ivan
d123d96ea3 F: Improve animations and styling 2025-02-22 16:12:29 +03:00
176 changed files with 2705 additions and 2353 deletions

View File

@ -45,7 +45,6 @@ This readme file is used mostly to document project dependencies and conventions
- js-file-download - js-file-download
- use-debounce - use-debounce
- qrcode.react - qrcode.react
- html-to-image
- zustand - zustand
- zod - zod
- @hookform/resolvers - @hookform/resolvers

View File

@ -8,9 +8,10 @@ For more specific TODOs see comments in code
- Landing page - Landing page
- Design first user experience - Design first user experience
- Demo sandbox for anonymous users - Demo sandbox for anonymous users
- Save react-flow to vector image
User profile: User profile:
- Settings + settings server persistency - Settings server persistency
- Profile pictures - Profile pictures
- Custom LibraryItem lists - Custom LibraryItem lists
- Custom user filters and sharing filters - Custom user filters and sharing filters
@ -39,7 +40,6 @@ User profile:
[Tech] [Tech]
- duplicate syntax parsing and type info calculations to client. Consider moving backend to Nodejs or embedding c++ lib - duplicate syntax parsing and type info calculations to client. Consider moving backend to Nodejs or embedding c++ lib
- Testing E2E playwright
[Deployment] [Deployment]
@ -60,7 +60,6 @@ Research and consider integration
- skeleton loading - skeleton loading
https://react.dev/reference/react/Suspense https://react.dev/reference/react/Suspense
- backend error message unification
- drf-messages - drf-messages
https://drf-standardized-errors.readthedocs.io/en/latest/error_response.html https://drf-standardized-errors.readthedocs.io/en/latest/error_response.html

File diff suppressed because it is too large Load Diff

View File

@ -14,17 +14,16 @@
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^4.1.0", "@hookform/resolvers": "^4.1.2",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.66.8", "@tanstack/react-query": "^5.66.9",
"@tanstack/react-query-devtools": "^5.66.8", "@tanstack/react-query-devtools": "^5.66.9",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@uiw/codemirror-themes": "^4.23.8", "@uiw/codemirror-themes": "^4.23.8",
"@uiw/react-codemirror": "^4.23.8", "@uiw/react-codemirror": "^4.23.8",
"axios": "^1.7.9", "axios": "^1.8.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"global": "^4.4.0", "global": "^4.4.0",
"html-to-image": "^1.11.13",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
@ -34,10 +33,10 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-intl": "^7.1.6", "react-intl": "^7.1.6",
"react-router": "^7.2.0", "react-router": "^7.2.0",
"react-scan": "^0.1.3", "react-scan": "^0.1.4",
"react-select": "^5.10.0", "react-select": "^5.10.0",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-toastify": "^11.0.3", "react-toastify": "^11.0.5",
"react-tooltip": "^5.28.0", "react-tooltip": "^5.28.0",
"react-zoom-pan-pinch": "^3.7.0", "react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
@ -48,16 +47,16 @@
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.7.2", "@lezer/generator": "^1.7.2",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@tailwindcss/vite": "^4.0.7", "@tailwindcss/vite": "^4.0.9",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.4", "@types/node": "^22.13.5",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1", "@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"babel-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216", "babel-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216",
"eslint": "^9.20.1", "eslint": "^9.21.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216", "eslint-plugin-react-compiler": "^19.0.0-beta-21e868a-20250216",
@ -66,10 +65,10 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.6",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.24.1", "typescript-eslint": "^8.25.0",
"vite": "^6.1.1" "vite": "^6.2.0"
}, },
"overrides": { "overrides": {
"react": "^19.0.0" "react": "^19.0.0"

View File

@ -27,7 +27,7 @@ export function ApplicationLayout() {
<NavigationState> <NavigationState>
<div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'> <div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'>
<ToasterThemed <ToasterThemed
className='text-[14px] cc-animate-position' className='text-[14px]'
style={{ marginTop: noNavigationAnimation ? '1.5rem' : '3.5rem' }} style={{ marginTop: noNavigationAnimation ? '1.5rem' : '3.5rem' }}
autoClose={3000} autoClose={3000}
draggable={false} draggable={false}

View File

@ -1,24 +1,9 @@
'use client'; 'use client';
import React, { Suspense } from 'react';
import { Tooltip } from '@/components/Container'; import { Tooltip } from '@/components/Container';
import { Loader } from '@/components/Loader';
import { useTooltipsStore } from '@/stores/tooltips';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
const InfoConstituenta = React.lazy(() =>
import('@/features/rsform/components/InfoConstituenta').then(module => ({ default: module.InfoConstituenta }))
);
const InfoOperation = React.lazy(() =>
import('@/features/oss/components/InfoOperation').then(module => ({ default: module.InfoOperation }))
);
export const GlobalTooltips = () => { export const GlobalTooltips = () => {
const hoverCst = useTooltipsStore(state => state.activeCst);
const hoverOperation = useTooltipsStore(state => state.activeOperation);
return ( return (
<> <>
<Tooltip <Tooltip
@ -34,27 +19,6 @@ export const GlobalTooltips = () => {
layer='z-topmost' layer='z-topmost'
className='max-w-[calc(min(40rem,100dvw-2rem))] text-justify' className='max-w-[calc(min(40rem,100dvw-2rem))] text-justify'
/> />
<Tooltip
clickable
id={globalIDs.constituenta_tooltip}
layer='z-modalTooltip'
className='max-w-[30rem]'
hidden={!hoverCst}
>
<Suspense fallback={<Loader />}>
{hoverCst ? <InfoConstituenta data={hoverCst} onClick={event => event.stopPropagation()} /> : null}
</Suspense>
</Tooltip>
<Tooltip
id={globalIDs.operation_tooltip}
layer='z-modalTooltip'
className='max-w-[35rem] max-h-[40rem] dense'
hidden={!hoverOperation}
>
<Suspense fallback={<Loader />}>
{hoverOperation ? <InfoOperation operation={hoverOperation} /> : null}
</Suspense>
</Tooltip>
</> </>
); );
}; };

View File

@ -18,12 +18,14 @@ export function Navigation() {
const size = useWindowSize(); const size = useWindowSize();
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const navigateHome = (event: React.MouseEvent<Element>) => router.push(urls.home, event.ctrlKey || event.metaKey); const navigateHome = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.home, newTab: event.ctrlKey || event.metaKey });
const navigateLibrary = (event: React.MouseEvent<Element>) => const navigateLibrary = (event: React.MouseEvent<Element>) =>
router.push(urls.library, event.ctrlKey || event.metaKey); router.push({ path: urls.library, newTab: event.ctrlKey || event.metaKey });
const navigateHelp = (event: React.MouseEvent<Element>) => router.push(urls.manuals, event.ctrlKey || event.metaKey); const navigateHelp = (event: React.MouseEvent<Element>) =>
router.push({ path: urls.manuals, newTab: event.ctrlKey || event.metaKey });
const navigateCreateNew = (event: React.MouseEvent<Element>) => const navigateCreateNew = (event: React.MouseEvent<Element>) =>
router.push(urls.create_schema, event.ctrlKey || event.metaKey); router.push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey });
return ( return (
<nav <nav
@ -42,9 +44,10 @@ export function Navigation() {
'cc-shadow-border' 'cc-shadow-border'
)} )}
style={{ style={{
transitionProperty: 'height, translate', willChange: 'max-height, translate',
transitionProperty: 'max-height, translate',
transitionDuration: `${PARAMETER.moveDuration}ms`, transitionDuration: `${PARAMETER.moveDuration}ms`,
height: noNavigationAnimation ? '0rem' : '3rem', maxHeight: noNavigationAnimation ? '0rem' : '3rem',
translate: noNavigationAnimation ? '0 -1.5rem' : '0' translate: noNavigationAnimation ? '0 -1.5rem' : '0'
}} }}
> >

View File

@ -3,13 +3,19 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { contextOutsideScope } from '@/utils/labels'; export interface NavigationProps {
path: string;
newTab?: boolean;
force?: boolean;
}
interface INavigationContext { interface INavigationContext {
push: (path: string, newTab?: boolean) => void; push: (props: NavigationProps) => void;
replace: (path: string) => void; pushAsync: (props: NavigationProps) => void | Promise<void>;
back: () => void; replace: (props: Omit<NavigationProps, 'newTab'>) => void;
forward: () => void; replaceAsync: (props: Omit<NavigationProps, 'newTab'>) => void | Promise<void>;
back: (force?: boolean) => void;
forward: (force?: boolean) => void;
canBack: () => boolean; canBack: () => boolean;
@ -21,7 +27,7 @@ const NavigationContext = createContext<INavigationContext | null>(null);
export const useConceptNavigation = () => { export const useConceptNavigation = () => {
const context = useContext(NavigationContext); const context = useContext(NavigationContext);
if (!context) { if (!context) {
throw new Error(contextOutsideScope('useConceptNavigation', 'NavigationState')); throw new Error('useConceptNavigation has to be used within <NavigationState>');
} }
return context; return context;
}; };
@ -39,33 +45,47 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
return !!window.history && window.history?.length !== 0; return !!window.history && window.history?.length !== 0;
} }
function push(path: string, newTab?: boolean) { function push(props: NavigationProps) {
if (newTab) { if (props.newTab) {
window.open(`${path}`, '_blank'); window.open(`${props.path}`, '_blank');
return; } else if (props.force || validate()) {
}
if (validate()) {
Promise.resolve(router(path, { viewTransition: true })).catch(console.error);
setIsBlocked(false); setIsBlocked(false);
Promise.resolve(router(props.path, { viewTransition: true })).catch(console.error);
} }
} }
function replace(path: string) { function pushAsync(props: NavigationProps): void | Promise<void> {
if (validate()) { if (props.newTab) {
Promise.resolve(router(path, { replace: true, viewTransition: true })).catch(console.error); window.open(`${props.path}`, '_blank');
} else if (props.force || validate()) {
setIsBlocked(false); setIsBlocked(false);
return router(props.path, { viewTransition: true });
} }
} }
function back() { function replace(props: Omit<NavigationProps, 'newTab'>) {
if (validate()) { if (props.force || validate()) {
setIsBlocked(false);
Promise.resolve(router(props.path, { replace: true, viewTransition: true })).catch(console.error);
}
}
function replaceAsync(props: Omit<NavigationProps, 'newTab'>): void | Promise<void> {
if (props.force || validate()) {
setIsBlocked(false);
return router(props.path, { replace: true, viewTransition: true });
}
}
function back(force?: boolean) {
if (force || validate()) {
Promise.resolve(router(-1)).catch(console.error); Promise.resolve(router(-1)).catch(console.error);
setIsBlocked(false); setIsBlocked(false);
} }
} }
function forward() { function forward(force?: boolean) {
if (validate()) { if (force || validate()) {
Promise.resolve(router(1)).catch(console.error); Promise.resolve(router(1)).catch(console.error);
setIsBlocked(false); setIsBlocked(false);
} }
@ -75,7 +95,9 @@ export const NavigationState = ({ children }: React.PropsWithChildren) => {
<NavigationContext <NavigationContext
value={{ value={{
push, push,
pushAsync,
replace, replace,
replaceAsync,
back, back,
forward, forward,
canBack, canBack,

View File

@ -22,7 +22,8 @@ export function ToggleNavigation() {
!noNavigation && 'flex-col-reverse' !noNavigation && 'flex-col-reverse'
)} )}
style={{ style={{
transitionProperty: 'height, width, background-color', willChange: 'height, width',
transitionProperty: 'height, width',
transitionDuration: `${PARAMETER.moveDuration}ms`, transitionDuration: `${PARAMETER.moveDuration}ms`,
height: noNavigationAnimation ? '2rem' : '3rem', height: noNavigationAnimation ? '2rem' : '3rem',
width: noNavigationAnimation ? '3rem' : '2rem' width: noNavigationAnimation ? '3rem' : '2rem'
@ -32,7 +33,7 @@ export function ToggleNavigation() {
<button <button
tabIndex={-1} tabIndex={-1}
type='button' type='button'
className='p-1' className='p-1 cursor-pointer'
onClick={toggleDarkMode} onClick={toggleDarkMode}
data-tooltip-id={globalIDs.tooltip} data-tooltip-id={globalIDs.tooltip}
data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'} data-tooltip-content={darkMode ? 'Тема: Темная' : 'Тема: Светлая'}
@ -44,7 +45,7 @@ export function ToggleNavigation() {
<button <button
tabIndex={-1} tabIndex={-1}
type='button' type='button'
className='p-1' className='p-1 cursor-pointer'
onClick={toggleNoNavigation} onClick={toggleNoNavigation}
data-tooltip-id={globalIDs.tooltip} data-tooltip-id={globalIDs.tooltip}
data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'} data-tooltip-content={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}

View File

@ -1,4 +1,5 @@
import { useAuthSuspense, useLogout } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { useLogout } from '@/features/auth/backend/useLogout';
import { Dropdown, DropdownButton } from '@/components/Dropdown'; import { Dropdown, DropdownButton } from '@/components/Dropdown';
import { import {
@ -40,32 +41,32 @@ export function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
function navigateProfile(event: React.MouseEvent<Element>) { function navigateProfile(event: React.MouseEvent<Element>) {
hideDropdown(); hideDropdown();
router.push(urls.profile, event.ctrlKey || event.metaKey); router.push({ path: urls.profile, newTab: event.ctrlKey || event.metaKey });
} }
function logoutAndRedirect() { function logoutAndRedirect() {
hideDropdown(); hideDropdown();
void logout().then(() => router.push(urls.login)); void logout().then(() => router.push({ path: urls.login, force: true }));
} }
function gotoAdmin() { function gotoAdmin() {
hideDropdown(); hideDropdown();
void logout().then(() => router.push(urls.admin, true)); void logout().then(() => router.push({ path: urls.admin, force: true, newTab: true }));
} }
function gotoIcons(event: React.MouseEvent<Element>) { function gotoIcons(event: React.MouseEvent<Element>) {
hideDropdown(); hideDropdown();
router.push(urls.icons, event.ctrlKey || event.metaKey); router.push({ path: urls.icons, newTab: event.ctrlKey || event.metaKey });
} }
function gotoRestApi() { function gotoRestApi() {
hideDropdown(); hideDropdown();
router.push(urls.rest_api, true); router.push({ path: urls.rest_api, newTab: true });
} }
function gotoDatabaseSchema(event: React.MouseEvent<Element>) { function gotoDatabaseSchema(event: React.MouseEvent<Element>) {
hideDropdown(); hideDropdown();
router.push(urls.database_schema, event.ctrlKey || event.metaKey); router.push({ path: urls.database_schema, newTab: event.ctrlKey || event.metaKey });
} }
function handleToggleDarkMode() { function handleToggleDarkMode() {

View File

@ -15,7 +15,7 @@ export function UserMenu() {
return ( return (
<div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'> <div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'>
<Suspense fallback={<Loader circular scale={1.5} />}> <Suspense fallback={<Loader circular scale={1.5} />}>
<UserButton onLogin={() => router.push(urls.login)} onClickUser={menu.toggle} /> <UserButton onLogin={() => router.push({ path: urls.login, force: true })} onClickUser={menu.toggle} />
</Suspense> </Suspense>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} /> <UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
</div> </div>

View File

@ -6,6 +6,7 @@ import axios, { type AxiosError, type AxiosRequestConfig } from 'axios';
import { type z, ZodError } from 'zod'; import { type z, ZodError } from 'zod';
import { buildConstants } from '@/utils/buildConstants'; import { buildConstants } from '@/utils/buildConstants';
import { PARAMETER } from '@/utils/constants';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { extractErrorMessage } from '@/utils/utils'; import { extractErrorMessage } from '@/utils/utils';
@ -62,11 +63,7 @@ export function axiosGet<ResponseData>({ endpoint, options, schema }: IAxiosGetR
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
// Note: Ignore cancellation errors // Note: Ignore cancellation errors
if (error.name !== 'CanceledError') { if (error.name !== 'CanceledError') {
if (error instanceof ZodError) { notifyError(error);
toast.error(errorMsg.invalidResponse);
} else {
toast.error(extractErrorMessage(error));
}
console.error(error); console.error(error);
} }
throw error; throw error;
@ -83,21 +80,11 @@ export function axiosPost<RequestData, ResponseData = void>({
.post<ResponseData>(endpoint, request?.data, options) .post<ResponseData>(endpoint, request?.data, options)
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
if (request?.successMessage) { notifySuccess(response.data, request?.successMessage);
if (typeof request.successMessage === 'string') {
toast.success(request.successMessage);
} else {
toast.success(request.successMessage(response.data));
}
}
return response.data; return response.data;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
if (error instanceof ZodError) { notifyError(error);
toast.error(errorMsg.invalidResponse);
} else {
toast.error(extractErrorMessage(error));
}
throw error; throw error;
}); });
} }
@ -112,21 +99,11 @@ export function axiosDelete<RequestData, ResponseData = void>({
.delete<ResponseData>(endpoint, options) .delete<ResponseData>(endpoint, options)
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
if (request?.successMessage) { notifySuccess(response.data, request?.successMessage);
if (typeof request.successMessage === 'string') {
toast.success(request.successMessage);
} else {
toast.success(request.successMessage(response.data));
}
}
return response.data; return response.data;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
if (error instanceof ZodError) { notifyError(error);
toast.error(errorMsg.invalidResponse);
} else {
toast.error(extractErrorMessage(error));
}
throw error; throw error;
}); });
} }
@ -141,21 +118,36 @@ export function axiosPatch<RequestData, ResponseData = void>({
.patch<ResponseData>(endpoint, request?.data, options) .patch<ResponseData>(endpoint, request?.data, options)
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
if (request?.successMessage) { notifySuccess(response.data, request?.successMessage);
if (typeof request.successMessage === 'string') {
toast.success(request.successMessage);
} else {
toast.success(request.successMessage(response.data));
}
}
return response.data; return response.data;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error);
throw error;
});
}
// ====== Internals =========
function notifySuccess<ResponseData>(
data: ResponseData,
message: string | ((data: ResponseData) => string) | undefined
) {
if (!message) {
return;
}
setTimeout(() => {
if (typeof message === 'string') {
toast.success(message);
} else {
toast.success(message(data));
}
}, PARAMETER.notificationDelay);
}
function notifyError(error: Error | AxiosError | ZodError) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
toast.error(errorMsg.invalidResponse); toast.error(errorMsg.invalidResponse);
} else { } else {
toast.error(extractErrorMessage(error)); toast.error(extractErrorMessage(error));
} }
throw error;
});
} }

View File

@ -1,125 +0,0 @@
import { LocationHead } from '@/features/library/models/library';
import { ExpressionStatus } from '@/features/rsform/models/rsform';
import { CstMatchMode, DependencyMode } from '@/features/rsform/stores/cstSearch';
import {
IconAlias,
IconBusiness,
IconFilter,
IconFormula,
IconGraphCollapse,
IconGraphExpand,
IconGraphInputs,
IconGraphOutputs,
IconHide,
IconMoveDown,
IconMoveUp,
type IconProps,
IconPublic,
IconSettings,
IconShow,
IconStatusError,
IconStatusIncalculable,
IconStatusOK,
IconStatusUnknown,
IconSubfolders,
IconTemplates,
IconTerm,
IconText,
IconUser
} from './Icons';
export interface DomIconProps<RequestData> extends IconProps {
value: RequestData;
}
/** Icon for visibility. */
export function VisibilityIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconShow size={size} className={className ?? 'text-ok-600'} />;
} else {
return <IconHide size={size} className={className ?? 'text-warn-600'} />;
}
}
/** Icon for subfolders. */
export function SubfoldersIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconSubfolders size={size} className={className ?? 'text-ok-600'} />;
} else {
return <IconSubfolders size={size} className={className ?? 'text-sec-600'} />;
}
}
/** Icon for location. */
export function LocationIcon({ value, size = '1.25rem', className }: DomIconProps<string>) {
switch (value.substring(0, 2) as LocationHead) {
case LocationHead.COMMON:
return <IconPublic size={size} className={className ?? 'text-sec-600'} />;
case LocationHead.LIBRARY:
return <IconTemplates size={size} className={className ?? 'text-warn-600'} />;
case LocationHead.PROJECTS:
return <IconBusiness size={size} className={className ?? 'text-sec-600'} />;
case LocationHead.USER:
return <IconUser size={size} className={className ?? 'text-ok-600'} />;
}
}
/** Icon for term graph dependency mode. */
export function DependencyIcon({ value, size = '1.25rem', className }: DomIconProps<DependencyMode>) {
switch (value) {
case DependencyMode.ALL:
return <IconSettings size={size} className={className} />;
case DependencyMode.OUTPUTS:
return <IconGraphOutputs size={size} className={className ?? 'text-sec-600'} />;
case DependencyMode.INPUTS:
return <IconGraphInputs size={size} className={className ?? 'text-sec-600'} />;
case DependencyMode.EXPAND_OUTPUTS:
return <IconGraphExpand size={size} className={className ?? 'text-sec-600'} />;
case DependencyMode.EXPAND_INPUTS:
return <IconGraphCollapse size={size} className={className ?? 'text-sec-600'} />;
}
}
/** Icon for constituenta match mode. */
export function MatchModeIcon({ value, size = '1.25rem', className }: DomIconProps<CstMatchMode>) {
switch (value) {
case CstMatchMode.ALL:
return <IconFilter size={size} className={className} />;
case CstMatchMode.TEXT:
return <IconText size={size} className={className ?? 'text-sec-600'} />;
case CstMatchMode.EXPR:
return <IconFormula size={size} className={className ?? 'text-sec-600'} />;
case CstMatchMode.TERM:
return <IconTerm size={size} className={className ?? 'text-sec-600'} />;
case CstMatchMode.NAME:
return <IconAlias size={size} className={className ?? 'text-sec-600'} />;
}
}
/** Icon for expression status. */
export function StatusIcon({ value, size = '1.25rem', className }: DomIconProps<ExpressionStatus>) {
switch (value) {
case ExpressionStatus.VERIFIED:
case ExpressionStatus.PROPERTY:
return <IconStatusOK size={size} className={className} />;
case ExpressionStatus.UNKNOWN:
return <IconStatusUnknown size={size} className={className} />;
case ExpressionStatus.INCALCULABLE:
return <IconStatusIncalculable size={size} className={className} />;
case ExpressionStatus.INCORRECT:
case ExpressionStatus.UNDEFINED:
return <IconStatusError size={size} className={className} />;
}
}
/** Icon for relocation direction. */
export function RelocateUpIcon({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconMoveUp size={size} className={className ?? 'text-sec-600'} />;
} else {
return <IconMoveDown size={size} className={className ?? 'text-sec-600'} />;
}
}

View File

@ -46,6 +46,7 @@ export function Dropdown({
className className
)} )}
style={{ style={{
willChange: 'clip-path, transform',
transitionProperty: 'clip-path, transform', transitionProperty: 'clip-path, transform',
transitionDuration: `${PARAMETER.dropdownDuration}ms`, transitionDuration: `${PARAMETER.dropdownDuration}ms`,
transitionTimingFunction: 'ease-in-out', transitionTimingFunction: 'ease-in-out',

View File

@ -155,6 +155,10 @@ export { LuCircleDashed as IconAnimation } from 'react-icons/lu';
export { LuCircle as IconAnimationOff } from 'react-icons/lu'; export { LuCircle as IconAnimationOff } from 'react-icons/lu';
// ===== Custom elements ====== // ===== Custom elements ======
export interface DomIconProps<RequestData> extends IconProps {
value: RequestData;
}
interface IconSVGProps { interface IconSVGProps {
viewBox: string; viewBox: string;
size?: string; size?: string;

View File

@ -98,11 +98,12 @@ export function SelectTree<ItemType>({
onClick={event => handleSetValue(event, item)} onClick={event => handleSetValue(event, item)}
style={{ style={{
borderBottomWidth: isActive ? '1px' : '0px', borderBottomWidth: isActive ? '1px' : '0px',
transitionProperty: 'height, opacity, padding', willChange: 'max-height, opacity, padding',
transitionProperty: 'max-height, opacity, padding',
transitionDuration: `${PARAMETER.moveDuration}ms`, transitionDuration: `${PARAMETER.moveDuration}ms`,
paddingTop: isActive ? '0.25rem' : '0', paddingTop: isActive ? '0.25rem' : '0',
paddingBottom: isActive ? '0.25rem' : '0', paddingBottom: isActive ? '0.25rem' : '0',
height: isActive ? 'min-content' : '0', maxHeight: isActive ? '1.75rem' : '0',
opacity: isActive ? '1' : '0' opacity: isActive ? '1' : '0'
}} }}
> >

View File

@ -2,13 +2,15 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { BadgeHelp, type HelpTopic } from '@/features/help'; import { type HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
import { useEscapeKey } from '@/hooks/useEscapeKey'; import { useEscapeKey } from '@/hooks/useEscapeKey';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { Overlay } from '../Container';
import { Button, MiniButton, SubmitButton } from '../Control'; import { Button, MiniButton, SubmitButton } from '../Control';
import { IconClose } from '../Icons'; import { IconClose } from '../Icons';
import { type Styling } from '../props'; import { type Styling } from '../props';
@ -103,6 +105,7 @@ export function ModalForm({
</div> </div>
) : null} ) : null}
<Overlay className='z-modalOverlay'>
<MiniButton <MiniButton
noPadding noPadding
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')} titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
@ -110,6 +113,7 @@ export function ModalForm({
className='float-right mt-2 mr-2' className='float-right mt-2 mr-2'
onClick={handleCancel} onClick={handleCancel}
/> />
</Overlay>
{header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null} {header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null}
@ -127,7 +131,7 @@ export function ModalForm({
{children} {children}
</div> </div>
<div className='z-modalControls my-2 flex gap-12 justify-center text-sm'> <div className='z-modal-controls my-2 flex gap-12 justify-center text-sm'>
<SubmitButton <SubmitButton
autoFocus autoFocus
text={submitText} text={submitText}

View File

@ -2,13 +2,14 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { BadgeHelp } from '@/features/help'; import { BadgeHelp } from '@/features/help/components';
import { useEscapeKey } from '@/hooks/useEscapeKey'; import { useEscapeKey } from '@/hooks/useEscapeKey';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { Overlay } from '../Container';
import { Button, MiniButton } from '../Control'; import { Button, MiniButton } from '../Control';
import { IconClose } from '../Icons'; import { IconClose } from '../Icons';
@ -48,6 +49,7 @@ export function ModalView({
</div> </div>
) : null} ) : null}
<Overlay className='z-modalOverlay'>
<MiniButton <MiniButton
noPadding noPadding
titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')} titleHtml={prepareTooltip('Закрыть диалоговое окно', 'ESC')}
@ -55,6 +57,7 @@ export function ModalView({
className='float-right mt-2 mr-2' className='float-right mt-2 mr-2'
onClick={hideDialog} onClick={hideDialog}
/> />
</Overlay>
{header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null} {header ? <h1 className='px-12 py-2 select-none'>{header}</h1> : null}
@ -72,7 +75,7 @@ export function ModalView({
{children} {children}
</div> </div>
<div className='z-modalControls my-2 flex gap-12 justify-center text-sm'> <div className='z-modal-controls my-2 flex gap-12 justify-center text-sm'>
<Button text='Закрыть' className='min-w-[7rem]' onClick={hideDialog} /> <Button text='Закрыть' className='min-w-[7rem]' onClick={hideDialog} />
</div> </div>
</div> </div>

View File

@ -1,8 +1,10 @@
import { type Styling, type Titled } from '@/components/props'; import { type Styling, type Titled } from '@/components/props';
import { PARAMETER } from '@/utils/constants';
import { ValueIcon } from './ValueIcon'; import { ValueIcon } from './ValueIcon';
// characters - threshold for small labels - small font
const SMALL_THRESHOLD = 3;
interface ValueStatsProps extends Styling, Titled { interface ValueStatsProps extends Styling, Titled {
/** Id of the component. */ /** Id of the component. */
id: string; id: string;
@ -18,5 +20,5 @@ interface ValueStatsProps extends Styling, Titled {
* Displays statistics value with an icon. * Displays statistics value with an icon.
*/ */
export function ValueStats(props: ValueStatsProps) { export function ValueStats(props: ValueStatsProps) {
return <ValueIcon dense smallThreshold={PARAMETER.statSmallThreshold} textClassName='min-w-[1.4rem]' {...props} />; return <ValueIcon dense smallThreshold={SMALL_THRESHOLD} textClassName='min-w-[1.4rem]' {...props} />;
} }

View File

@ -11,7 +11,7 @@ export function ExpectedAnonymous() {
const router = useConceptNavigation(); const router = useConceptNavigation();
function logoutAndRedirect() { function logoutAndRedirect() {
void logout().then(() => router.push(urls.login)); void logout().then(() => router.push({ path: urls.login, force: true }));
} }
return ( return (

View File

@ -0,0 +1,2 @@
export { ExpectedAnonymous } from './ExpectedAnonymous';
export { RequireAuth } from './RequireAuth';

View File

@ -1,5 +1,2 @@
export * from './backend/types';
export { useAuthSuspense } from './backend/useAuth'; export { useAuthSuspense } from './backend/useAuth';
export { useChangePassword } from './backend/useChangePassword';
export { useLogout } from './backend/useLogout';
export { ExpectedAnonymous } from './components/ExpectedAnonymous';
export { RequireAuth } from './components/RequireAuth';

View File

@ -41,7 +41,7 @@ export function LoginPage() {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push({ path: urls.library, force: true });
} }
}); });
} }

View File

@ -33,8 +33,8 @@ export function Component() {
password: newPassword, password: newPassword,
token: token token: token
}).then(() => { }).then(() => {
router.replace(urls.home); router.replace({ path: urls.home });
router.push(urls.login); router.push({ path: urls.login });
}); });
} }
} }

View File

@ -39,7 +39,7 @@ export function BadgeHelp({ topic, padding = 'p-1', ...restProps }: BadgeHelpPro
return ( return (
<div tabIndex={-1} id={`help-${topic}`} className={padding}> <div tabIndex={-1} id={`help-${topic}`} className={padding}>
<IconHelp size='1.25rem' className='icon-primary' /> <IconHelp size='1.25rem' className='icon-primary' />
<Tooltip clickable anchorSelect={`#help-${topic}`} layer='z-modalTooltip' {...restProps}> <Tooltip clickable anchorSelect={`#help-${topic}`} layer='z-modal-tooltip' {...restProps}>
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>
<div className='relative' onClick={event => event.stopPropagation()}> <div className='relative' onClick={event => event.stopPropagation()}>
<div className='absolute right-0 text-sm top-[0.4rem] clr-input'> <div className='absolute right-0 text-sm top-[0.4rem] clr-input'>

View File

@ -1,8 +1,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { CstClass } from '@/features/rsform';
import { colorBgCstClass } from '@/features/rsform/colors'; import { colorBgCstClass } from '@/features/rsform/colors';
import { describeCstClass, labelCstClass } from '@/features/rsform/labels'; import { describeCstClass, labelCstClass } from '@/features/rsform/labels';
import { CstClass } from '@/features/rsform/models/rsform';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';

View File

@ -1,8 +1,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { ExpressionStatus } from '@/features/rsform';
import { colorBgCstStatus } from '@/features/rsform/colors'; import { colorBgCstStatus } from '@/features/rsform/colors';
import { describeExpressionStatus, labelExpressionStatus } from '@/features/rsform/labels'; import { describeExpressionStatus, labelExpressionStatus } from '@/features/rsform/labels';
import { ExpressionStatus } from '@/features/rsform/models/rsform';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';

View File

@ -0,0 +1 @@
export { BadgeHelp } from './BadgeHelp';

View File

@ -1,2 +1 @@
export { BadgeHelp } from './components/BadgeHelp';
export { HelpTopic } from './models/helpTopic'; export { HelpTopic } from './models/helpTopic';

View File

@ -10,7 +10,6 @@ import {
IconExecute, IconExecute,
IconFitImage, IconFitImage,
IconGrid, IconGrid,
IconImage,
IconLineStraight, IconLineStraight,
IconLineWave, IconLineWave,
IconNewItem, IconNewItem,
@ -82,9 +81,6 @@ export function HelpOssGraph() {
<li> <li>
<IconSave className='inline-icon' /> Сохранить положения <IconSave className='inline-icon' /> Сохранить положения
</li> </li>
<li>
<IconImage className='inline-icon' /> Сохранить в SVG
</li>
</div> </div>
<Divider vertical margins='mx-3' className='hidden sm:block' /> <Divider vertical margins='mx-3' className='hidden sm:block' />

View File

@ -11,7 +11,6 @@ import {
IconGraphInputs, IconGraphInputs,
IconGraphMaximize, IconGraphMaximize,
IconGraphOutputs, IconGraphOutputs,
IconImage,
IconNewItem, IconNewItem,
IconOSS, IconOSS,
IconPredecessor, IconPredecessor,
@ -85,9 +84,6 @@ export function HelpRSGraphTerm() {
<IconTypeGraph className='inline-icon' /> Открыть{' '} <IconTypeGraph className='inline-icon' /> Открыть{' '}
<LinkTopic text='граф ступеней' topic={HelpTopic.UI_TYPE_GRAPH} /> <LinkTopic text='граф ступеней' topic={HelpTopic.UI_TYPE_GRAPH} />
</li> </li>
<li>
<IconImage className='inline-icon' /> Сохранить в формат PNG
</li>
</div> </div>
<Divider vertical margins='mx-3' className='hidden sm:block' /> <Divider vertical margins='mx-3' className='hidden sm:block' />

View File

@ -4,7 +4,6 @@ import { urls, useConceptNavigation } from '@/app';
import { useQueryStrings } from '@/hooks/useQueryStrings'; import { useQueryStrings } from '@/hooks/useQueryStrings';
import { useMainHeight } from '@/stores/appLayout'; import { useMainHeight } from '@/stores/appLayout';
import { PARAMETER } from '@/utils/constants';
import { HelpTopic } from '../../models/helpTopic'; import { HelpTopic } from '../../models/helpTopic';
@ -19,13 +18,11 @@ export function ManualsPage() {
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
function onSelectTopic(newTopic: HelpTopic) { function onSelectTopic(newTopic: HelpTopic) {
router.push(urls.help_topic(newTopic)); router.push({ path: urls.help_topic(newTopic) });
} }
if (!Object.values(HelpTopic).includes(activeTopic)) { if (!Object.values(HelpTopic).includes(activeTopic)) {
setTimeout(() => { router.push({ path: urls.page404, force: true });
router.push(urls.page404);
}, PARAMETER.refreshTimeout);
return null; return null;
} }

View File

@ -33,7 +33,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
className={clsx( className={clsx(
'absolute left-0 w-[13.5rem]', // prettier: split-lines 'absolute left-0 w-[13.5rem]', // prettier: split-lines
'flex flex-col', 'flex flex-col',
'z-modalTooltip', 'z-modal-tooltip',
'text-xs sm:text-sm', 'text-xs sm:text-sm',
'select-none', 'select-none',
{ {
@ -66,6 +66,7 @@ export function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownPro
)} )}
style={{ style={{
maxHeight: treeHeight, maxHeight: treeHeight,
willChange: 'clip-path',
transitionProperty: 'clip-path', transitionProperty: 'clip-path',
transitionDuration: `${PARAMETER.moveDuration}ms`, transitionDuration: `${PARAMETER.moveDuration}ms`,
clipPath: menu.isOpen ? 'inset(0% 0% 0% 0%)' : 'inset(0% 100% 0% 0%)' clipPath: menu.isOpen ? 'inset(0% 0% 0% 0%)' : 'inset(0% 100% 0% 0%)'

View File

@ -6,9 +6,9 @@ export function HomePage() {
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
if (isAnonymous) { if (isAnonymous) {
router.replace(urls.manuals); router.replace({ path: urls.manuals });
} else { } else {
router.replace(urls.library); router.replace({ path: urls.library });
} }
return null; return null;

View File

@ -5,7 +5,7 @@ import {
type IVersionCreatedResponse, type IVersionCreatedResponse,
schemaRSForm, schemaRSForm,
schemaVersionCreatedResponse schemaVersionCreatedResponse
} from '@/features/rsform/backend/types'; } from '@/features/rsform';
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport'; import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS, KEYS } from '@/backend/configuration'; import { DELAYS, KEYS } from '@/backend/configuration';
@ -111,9 +111,9 @@ export const libraryApi = {
} }
}), }),
deleteItem: (target: number) => deleteItem: (data: { target: number; beforeInvalidate?: () => void | Promise<void> }) =>
axiosDelete({ axiosDelete({
endpoint: `/api/library/${target}`, endpoint: `/api/library/${data.target}`,
request: { request: {
successMessage: infoMsg.itemDestroyed successMessage: infoMsg.itemDestroyed
} }

View File

@ -10,22 +10,23 @@ export const useDeleteItem = () => {
const mutation = useMutation({ const mutation = useMutation({
mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-item'], mutationKey: [KEYS.global_mutation, libraryApi.baseKey, 'delete-item'],
mutationFn: libraryApi.deleteItem, mutationFn: libraryApi.deleteItem,
onSuccess: (_, variables) => { onSuccess: async (_, variables) => {
client.invalidateQueries({ queryKey: libraryApi.libraryListKey }).catch(console.error); await client.invalidateQueries({ queryKey: libraryApi.libraryListKey });
await Promise.resolve(variables.beforeInvalidate?.());
setTimeout( setTimeout(
() => () =>
void Promise.allSettled([ void Promise.allSettled([
client.invalidateQueries({ queryKey: [KEYS.oss] }), client.invalidateQueries({ queryKey: [KEYS.oss] }),
client.resetQueries({ queryKey: KEYS.composite.rsItem({ itemID: variables }) }), client.resetQueries({ queryKey: KEYS.composite.rsItem({ itemID: variables.target }) }),
client.resetQueries({ queryKey: KEYS.composite.ossItem({ itemID: variables }) }) client.resetQueries({ queryKey: KEYS.composite.ossItem({ itemID: variables.target }) })
]).catch(console.error), ]),
PARAMETER.navigationDuration PARAMETER.refreshTimeout
); );
}, },
onError: () => client.invalidateQueries() onError: () => client.invalidateQueries()
}); });
return { return {
deleteItem: (target: number) => mutation.mutateAsync(target), deleteItem: (data: { target: number; beforeInvalidate?: () => void | Promise<void> }) => mutation.mutateAsync(data),
isPending: mutation.isPending isPending: mutation.isPending
}; };
}; };

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IOperationSchemaDTO } from '@/features/oss/backend/types'; import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IOperationSchemaDTO } from '@/features/oss/backend/types'; import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IOperationSchemaDTO } from '@/features/oss/backend/types'; import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IOperationSchemaDTO } from '@/features/oss/backend/types'; import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IOperationSchemaDTO } from '@/features/oss/backend/types'; import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type IRSFormDTO } from '@/features/rsform/backend/types'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,7 @@
import { LocationIcon } from '@/components/DomainIcons';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { IconLocationHead } from './IconLocationHead';
interface BadgeLocationProps { interface BadgeLocationProps {
/** Location to display. */ /** Location to display. */
location: string; location: string;
@ -12,7 +13,7 @@ interface BadgeLocationProps {
export function BadgeLocation({ location }: BadgeLocationProps) { export function BadgeLocation({ location }: BadgeLocationProps) {
return ( return (
<div className='pl-2' data-tooltip-id={globalIDs.tooltip} data-tooltip-content={location}> <div className='pl-2' data-tooltip-id={globalIDs.tooltip} data-tooltip-content={location}>
<LocationIcon value={location} size='1.25rem' /> <IconLocationHead value={location} size='1.25rem' />
</div> </div>
); );
} }

View File

@ -2,7 +2,8 @@ import { Suspense } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { InfoUsers, SelectUser, useLabelUser, useRoleStore, UserRole } from '@/features/users'; import { useLabelUser, useRoleStore, UserRole } from '@/features/users';
import { InfoUsers, SelectUser } from '@/features/users/components';
import { Overlay, Tooltip } from '@/components/Container'; import { Overlay, Tooltip } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
@ -63,7 +64,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
function handleOpenLibrary(event: React.MouseEvent<Element>) { function handleOpenLibrary(event: React.MouseEvent<Element>) {
setGlobalLocation(schema.location); setGlobalLocation(schema.location);
router.push(urls.library, event.ctrlKey || event.metaKey); router.push({ path: urls.library, newTab: event.ctrlKey || event.metaKey });
} }
function handleEditLocation() { function handleEditLocation() {
@ -125,7 +126,7 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
onClick={handleEditEditors} onClick={handleEditEditors}
disabled={isModified || isProcessing || role < UserRole.OWNER} disabled={isModified || isProcessing || role < UserRole.OWNER}
/> />
<Tooltip anchorSelect='#editor_stats' layer='z-modalTooltip'> <Tooltip anchorSelect='#editor_stats' layer='z-modal-tooltip'>
<Suspense fallback={<Loader scale={2} />}> <Suspense fallback={<Loader scale={2} />}>
<InfoUsers items={schema.editors} prefix={prefixes.user_editors} header='Редакторы' /> <InfoUsers items={schema.editors} prefix={prefixes.user_editors} header='Редакторы' />
</Suspense> </Suspense>

View File

@ -0,0 +1,15 @@
import { type DomIconProps, IconPrivate, IconProtected, IconPublic } from '@/components/Icons';
import { AccessPolicy } from '../backend/types';
/** Icon for access policy. */
export function IconAccessPolicy({ value, size = '1.25rem', className }: DomIconProps<AccessPolicy>) {
switch (value) {
case AccessPolicy.PRIVATE:
return <IconPrivate size={size} className={className ?? 'text-warn-600'} />;
case AccessPolicy.PROTECTED:
return <IconProtected size={size} className={className ?? 'text-sec-600'} />;
case AccessPolicy.PUBLIC:
return <IconPublic size={size} className={className ?? 'text-ok-600'} />;
}
}

View File

@ -0,0 +1,10 @@
import { type DomIconProps, IconHide, IconShow } from '@/components/Icons';
/** Icon for visibility. */
export function IconItemVisibility({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconShow size={size} className={className ?? 'text-ok-600'} />;
} else {
return <IconHide size={size} className={className ?? 'text-warn-600'} />;
}
}

View File

@ -0,0 +1,13 @@
import { type DomIconProps, IconOSS, IconRSForm } from '@/components/Icons';
import { LibraryItemType } from '../backend/types';
/** Icon for library item type. */
export function IconLibraryItemType({ value, size = '1.25rem', className }: DomIconProps<LibraryItemType>) {
switch (value) {
case LibraryItemType.RSFORM:
return <IconRSForm size={size} className={className ?? 'text-sec-600'} />;
case LibraryItemType.OSS:
return <IconOSS size={size} className={className ?? 'text-ok-600'} />;
}
}

View File

@ -0,0 +1,17 @@
import { type DomIconProps, IconBusiness, IconPublic, IconTemplates, IconUser } from '@/components/Icons';
import { LocationHead } from '../models/library';
/** Icon for location. */
export function IconLocationHead({ value, size = '1.25rem', className }: DomIconProps<string>) {
switch (value.substring(0, 2) as LocationHead) {
case LocationHead.COMMON:
return <IconPublic size={size} className={className ?? 'text-sec-600'} />;
case LocationHead.LIBRARY:
return <IconTemplates size={size} className={className ?? 'text-warn-600'} />;
case LocationHead.PROJECTS:
return <IconBusiness size={size} className={className ?? 'text-sec-600'} />;
case LocationHead.USER:
return <IconUser size={size} className={className ?? 'text-ok-600'} />;
}
}

View File

@ -0,0 +1,21 @@
import { UserRole } from '@/features/users';
import { IconAdmin, IconEditor, IconOwner, IconReader } from '@/components/Icons';
interface IconRoleProps {
role: UserRole;
size?: string;
}
export function IconRole({ role, size = '1.25rem' }: IconRoleProps) {
switch (role) {
case UserRole.ADMIN:
return <IconAdmin size={size} className='icon-primary' />;
case UserRole.OWNER:
return <IconOwner size={size} className='icon-primary' />;
case UserRole.EDITOR:
return <IconEditor size={size} className='icon-primary' />;
case UserRole.READER:
return <IconReader size={size} className='icon-primary' />;
}
}

View File

@ -0,0 +1,10 @@
import { type DomIconProps, IconSubfolders } from '@/components/Icons';
/** Icon for subfolders. */
export function IconShowSubfolders({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconSubfolders size={size} className={className ?? 'text-ok-600'} />;
} else {
return <IconSubfolders size={size} className={className ?? 'text-sec-600'} />;
}
}

View File

@ -0,0 +1,90 @@
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useRoleStore, UserRole } from '@/features/users';
import { describeUserRole, labelUserRole } from '@/features/users/labels';
import { Button } from '@/components/Control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { IconAlert } from '@/components/Icons';
import { IconRole } from './IconRole';
interface MenuRoleProps {
isOwned: boolean;
isEditor: boolean;
}
export function MenuRole({ isOwned, isEditor }: MenuRoleProps) {
const { user, isAnonymous } = useAuthSuspense();
const router = useConceptNavigation();
const accessMenu = useDropdown();
const role = useRoleStore(state => state.role);
const setRole = useRoleStore(state => state.setRole);
function handleChangeMode(newMode: UserRole) {
accessMenu.hide();
setRole(newMode);
}
if (isAnonymous) {
return (
<Button
dense
noBorder
noOutline
tabIndex={-1}
titleHtml='<b>Анонимный режим</b><br />Войти в Портал'
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
icon={<IconAlert size='1.25rem' className='icon-red' />}
onClick={() => router.push({ path: urls.login })}
/>
);
}
return (
<div ref={accessMenu.ref}>
<Button
dense
noBorder
noOutline
tabIndex={-1}
title={`Режим ${labelUserRole(role)}`}
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
icon={<IconRole role={role} size='1.25rem' />}
onClick={accessMenu.toggle}
/>
<Dropdown isOpen={accessMenu.isOpen}>
<DropdownButton
text={labelUserRole(UserRole.READER)}
title={describeUserRole(UserRole.READER)}
icon={<IconRole role={UserRole.READER} size='1rem' />}
onClick={() => handleChangeMode(UserRole.READER)}
/>
<DropdownButton
text={labelUserRole(UserRole.EDITOR)}
title={describeUserRole(UserRole.EDITOR)}
icon={<IconRole role={UserRole.EDITOR} size='1rem' />}
disabled={!isOwned && !isEditor}
onClick={() => handleChangeMode(UserRole.EDITOR)}
/>
<DropdownButton
text={labelUserRole(UserRole.OWNER)}
title={describeUserRole(UserRole.OWNER)}
icon={<IconRole role={UserRole.OWNER} size='1rem' />}
disabled={!isOwned}
onClick={() => handleChangeMode(UserRole.OWNER)}
/>
<DropdownButton
text={labelUserRole(UserRole.ADMIN)}
title={describeUserRole(UserRole.ADMIN)}
icon={<IconRole role={UserRole.ADMIN} size='1rem' />}
disabled={!user.is_staff}
onClick={() => handleChangeMode(UserRole.ADMIN)}
/>
</Dropdown>
</div>
);
}

View File

@ -13,13 +13,13 @@ import { type ILibraryItemReference } from '../models/library';
interface MiniSelectorOSSProps extends Styling { interface MiniSelectorOSSProps extends Styling {
items: ILibraryItemReference[]; items: ILibraryItemReference[];
onSelect: (event: React.MouseEvent<Element>, newValue: ILibraryItemReference) => void; onSelect: (event: React.MouseEvent<HTMLElement>, newValue: ILibraryItemReference) => void;
} }
export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: MiniSelectorOSSProps) { export function MiniSelectorOSS({ items, onSelect, className, ...restProps }: MiniSelectorOSSProps) {
const ossMenu = useDropdown(); const ossMenu = useDropdown();
function onToggle(event: React.MouseEvent<Element>) { function onToggle(event: React.MouseEvent<HTMLElement>) {
if (items.length > 1) { if (items.length > 1) {
ossMenu.toggle(); ossMenu.toggle();
} else { } else {

View File

@ -120,7 +120,7 @@ export function PickSchema({
className='mt-1' className='mt-1'
onClick={() => locationMenu.toggle()} onClick={() => locationMenu.toggle()}
/> />
<Dropdown isOpen={locationMenu.isOpen} stretchLeft className='w-[20rem] h-[12.5rem] z-modalTooltip mt-0'> <Dropdown isOpen={locationMenu.isOpen} stretchLeft className='w-[20rem] h-[12.5rem] z-modal-tooltip mt-0'>
<SelectLocation <SelectLocation
value={filterLocation} value={filterLocation}
prefix={prefixes.folders_list} prefix={prefixes.folders_list}

View File

@ -1,15 +1,15 @@
'use client'; 'use client';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { type DomIconProps } from '@/components/DomainIcons';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { IconPrivate, IconProtected, IconPublic } from '@/components/Icons';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { AccessPolicy } from '../backend/types'; import { AccessPolicy } from '../backend/types';
import { describeAccessPolicy, labelAccessPolicy } from '../labels'; import { describeAccessPolicy, labelAccessPolicy } from '../labels';
import { IconAccessPolicy } from './IconAccessPolicy';
interface SelectAccessPolicyProps extends Styling { interface SelectAccessPolicyProps extends Styling {
value: AccessPolicy; value: AccessPolicy;
onChange: (value: AccessPolicy) => void; onChange: (value: AccessPolicy) => void;
@ -34,7 +34,7 @@ export function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...
title={`Доступ: ${labelAccessPolicy(value)}`} title={`Доступ: ${labelAccessPolicy(value)}`}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
className='h-full' className='h-full'
icon={<PolicyIcon value={value} size='1.25rem' />} icon={<IconAccessPolicy value={value} size='1.25rem' />}
onClick={menu.toggle} onClick={menu.toggle}
disabled={disabled} disabled={disabled}
/> />
@ -44,7 +44,7 @@ export function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...
key={`${prefixes.policy_list}${index}`} key={`${prefixes.policy_list}${index}`}
text={labelAccessPolicy(item)} text={labelAccessPolicy(item)}
title={describeAccessPolicy(item)} title={describeAccessPolicy(item)}
icon={<PolicyIcon value={item} size='1rem' />} icon={<IconAccessPolicy value={item} size='1rem' />}
onClick={() => handleChange(item)} onClick={() => handleChange(item)}
/> />
))} ))}
@ -52,15 +52,3 @@ export function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...
</div> </div>
); );
} }
/** Icon for access policy. */
function PolicyIcon({ value, size = '1.25rem', className }: DomIconProps<AccessPolicy>) {
switch (value) {
case AccessPolicy.PRIVATE:
return <IconPrivate size={size} className={className ?? 'text-warn-600'} />;
case AccessPolicy.PROTECTED:
return <IconProtected size={size} className={className ?? 'text-sec-600'} />;
case AccessPolicy.PUBLIC:
return <IconPublic size={size} className={className ?? 'text-ok-600'} />;
}
}

View File

@ -1,15 +1,15 @@
'use client'; 'use client';
import { SelectorButton } from '@/components/Control'; import { SelectorButton } from '@/components/Control';
import { type DomIconProps } from '@/components/DomainIcons';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { IconOSS, IconRSForm } from '@/components/Icons';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { LibraryItemType } from '../backend/types'; import { LibraryItemType } from '../backend/types';
import { describeLibraryItemType, labelLibraryItemType } from '../labels'; import { describeLibraryItemType, labelLibraryItemType } from '../labels';
import { IconLibraryItemType } from './IconLibraryItemType';
interface SelectItemTypeProps extends Styling { interface SelectItemTypeProps extends Styling {
value: LibraryItemType; value: LibraryItemType;
onChange: (value: LibraryItemType) => void; onChange: (value: LibraryItemType) => void;
@ -34,7 +34,7 @@ export function SelectItemType({ value, disabled, stretchLeft, onChange, ...rest
title={describeLibraryItemType(value)} title={describeLibraryItemType(value)}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
className='h-full px-2 py-1 rounded-lg' className='h-full px-2 py-1 rounded-lg'
icon={<ItemTypeIcon value={value} size='1.25rem' />} icon={<IconLibraryItemType value={value} size='1.25rem' />}
text={labelLibraryItemType(value)} text={labelLibraryItemType(value)}
onClick={menu.toggle} onClick={menu.toggle}
disabled={disabled} disabled={disabled}
@ -45,7 +45,7 @@ export function SelectItemType({ value, disabled, stretchLeft, onChange, ...rest
key={`${prefixes.policy_list}${index}`} key={`${prefixes.policy_list}${index}`}
text={labelLibraryItemType(item)} text={labelLibraryItemType(item)}
title={describeLibraryItemType(item)} title={describeLibraryItemType(item)}
icon={<ItemTypeIcon value={item} size='1rem' />} icon={<IconLibraryItemType value={item} size='1rem' />}
onClick={() => handleChange(item)} onClick={() => handleChange(item)}
/> />
))} ))}
@ -53,13 +53,3 @@ export function SelectItemType({ value, disabled, stretchLeft, onChange, ...rest
</div> </div>
); );
} }
/** Icon for library item type. */
function ItemTypeIcon({ value, size = '1.25rem', className }: DomIconProps<LibraryItemType>) {
switch (value) {
case LibraryItemType.RSFORM:
return <IconRSForm size={size} className={className ?? 'text-sec-600'} />;
case LibraryItemType.OSS:
return <IconOSS size={size} className={className ?? 'text-ok-600'} />;
}
}

View File

@ -43,7 +43,7 @@ export function SelectLocationContext({
/> />
<Dropdown <Dropdown
isOpen={menu.isOpen} isOpen={menu.isOpen}
className={clsx('w-[20rem] h-[12.5rem] z-modalTooltip mt-[-0.25rem]', className)} className={clsx('w-[20rem] h-[12.5rem] z-modal-tooltip mt-[-0.25rem]', className)}
style={style} style={style}
> >
<SelectLocation <SelectLocation

View File

@ -3,7 +3,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { SelectorButton } from '@/components/Control'; import { SelectorButton } from '@/components/Control';
import { LocationIcon } from '@/components/DomainIcons';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
@ -11,6 +10,8 @@ import { prefixes } from '@/utils/constants';
import { describeLocationHead, labelLocationHead } from '../labels'; import { describeLocationHead, labelLocationHead } from '../labels';
import { LocationHead } from '../models/library'; import { LocationHead } from '../models/library';
import { IconLocationHead } from './IconLocationHead';
interface SelectLocationHeadProps extends Styling { interface SelectLocationHeadProps extends Styling {
value: LocationHead; value: LocationHead;
onChange: (newValue: LocationHead) => void; onChange: (newValue: LocationHead) => void;
@ -39,12 +40,12 @@ export function SelectLocationHead({
title={describeLocationHead(value)} title={describeLocationHead(value)}
hideTitle={menu.isOpen} hideTitle={menu.isOpen}
className='h-full' className='h-full'
icon={<LocationIcon value={value} size='1rem' />} icon={<IconLocationHead value={value} size='1rem' />}
text={labelLocationHead(value)} text={labelLocationHead(value)}
onClick={menu.toggle} onClick={menu.toggle}
/> />
<Dropdown isOpen={menu.isOpen} className='z-modalTooltip'> <Dropdown isOpen={menu.isOpen} className='z-modal-tooltip'>
{Object.values(LocationHead) {Object.values(LocationHead)
.filter(head => !excluded.includes(head)) .filter(head => !excluded.includes(head))
.map((head, index) => { .map((head, index) => {
@ -56,7 +57,7 @@ export function SelectLocationHead({
title={describeLocationHead(head)} title={describeLocationHead(head)}
> >
<div className='inline-flex items-center gap-3'> <div className='inline-flex items-center gap-3'>
<LocationIcon value={head} size='1rem' /> <IconLocationHead value={head} size='1rem' />
{labelLocationHead(head)} {labelLocationHead(head)}
</div> </div>
</DropdownButton> </DropdownButton>

View File

@ -1,9 +1,9 @@
import { BadgeHelp, HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
import { Overlay } from '@/components/Container'; import { Overlay } from '@/components/Container';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { VisibilityIcon } from '@/components/DomainIcons';
import { IconImmutable, IconMutable } from '@/components/Icons'; import { IconImmutable, IconMutable } from '@/components/Icons';
import { Label } from '@/components/Input'; import { Label } from '@/components/Input';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
@ -12,6 +12,7 @@ import { type AccessPolicy, type ILibraryItem } from '../backend/types';
import { useMutatingLibrary } from '../backend/useMutatingLibrary'; import { useMutatingLibrary } from '../backend/useMutatingLibrary';
import { useSetAccessPolicy } from '../backend/useSetAccessPolicy'; import { useSetAccessPolicy } from '../backend/useSetAccessPolicy';
import { IconItemVisibility } from './IconItemVisibility';
import { SelectAccessPolicy } from './SelectAccessPolicy'; import { SelectAccessPolicy } from './SelectAccessPolicy';
interface ToolbarItemAccessProps { interface ToolbarItemAccessProps {
@ -52,7 +53,7 @@ export function ToolbarItemAccess({
<MiniButton <MiniButton
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'} title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={visible} />} icon={<IconItemVisibility value={visible} />}
onClick={toggleVisible} onClick={toggleVisible}
disabled={role === UserRole.READER || isProcessing} disabled={role === UserRole.READER || isProcessing}
/> />

View File

@ -0,0 +1,7 @@
export { EditorLibraryItem } from './EditorLibraryItem';
export { MenuRole } from './MenuRole';
export { MiniSelectorOSS } from './MiniSelectorOSS';
export { PickSchema } from './PickSchema';
export { SelectLibraryItem } from './SelectLibraryItem';
export { SelectVersion } from './SelectVersion';
export { ToolbarItemAccess } from './ToolbarItemAccess';

View File

@ -8,13 +8,13 @@ import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { VisibilityIcon } from '@/components/DomainIcons';
import { Checkbox, Label, TextArea, TextInput } from '@/components/Input'; import { Checkbox, Label, TextArea, TextInput } from '@/components/Input';
import { ModalForm } from '@/components/Modal'; import { ModalForm } from '@/components/Modal';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { AccessPolicy, type ICloneLibraryItemDTO, type ILibraryItem, schemaCloneLibraryItem } from '../backend/types'; import { AccessPolicy, type ICloneLibraryItemDTO, type ILibraryItem, schemaCloneLibraryItem } from '../backend/types';
import { useCloneItem } from '../backend/useCloneItem'; import { useCloneItem } from '../backend/useCloneItem';
import { IconItemVisibility } from '../components/IconItemVisibility';
import { SelectAccessPolicy } from '../components/SelectAccessPolicy'; import { SelectAccessPolicy } from '../components/SelectAccessPolicy';
import { SelectLocationContext } from '../components/SelectLocationContext'; import { SelectLocationContext } from '../components/SelectLocationContext';
import { SelectLocationHead } from '../components/SelectLocationHead'; import { SelectLocationHead } from '../components/SelectLocationHead';
@ -60,7 +60,7 @@ export function DlgCloneLibraryItem() {
}); });
function onSubmit(data: ICloneLibraryItemDTO) { function onSubmit(data: ICloneLibraryItemDTO) {
return cloneItem(data).then(newSchema => router.push(urls.schema(newSchema.id))); return cloneItem(data).then(newSchema => router.pushAsync({ path: urls.schema(newSchema.id), force: true }));
} }
return ( return (
@ -105,7 +105,7 @@ export function DlgCloneLibraryItem() {
render={({ field }) => ( render={({ field }) => (
<MiniButton <MiniButton
title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'} title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={field.value} />} icon={<IconItemVisibility value={field.value} />}
onClick={() => field.onChange(!field.value)} onClick={() => field.onChange(!field.value)}
/> />
)} )}

View File

@ -3,7 +3,8 @@
import { useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { SelectUser, TableUsers, useUsers } from '@/features/users'; import { useUsers } from '@/features/users';
import { SelectUser, TableUsers } from '@/features/users/components';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconRemove } from '@/components/Icons'; import { IconRemove } from '@/components/Icons';

View File

@ -5,7 +5,7 @@ import { useMemo } 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';
import { useRSFormSuspense } from '@/features/rsform'; import { useRSFormSuspense } from '@/features/rsform/backend/useRSForm';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconReset, IconSave } from '@/components/Icons'; import { IconReset, IconSave } from '@/components/Icons';

View File

@ -1,16 +1,13 @@
export { AccessPolicy, type ILibraryItem, type IVersionInfo, LibraryItemType } from './backend/types'; export {
export { useDeleteItem } from './backend/useDeleteItem'; AccessPolicy,
export { useLibrary, useLibrarySuspense } from './backend/useLibrary'; type ILibraryItem,
export { useMutatingLibrary } from './backend/useMutatingLibrary'; type ILibraryItemData,
export { useTemplatesSuspense } from './backend/useTemplates'; type IUpdateLibraryItemDTO,
export { useUpdateItem } from './backend/useUpdateItem'; type IVersionInfo,
export { useUpdateTimestamp } from './backend/useUpdateTimestamp'; LibraryItemType,
export { useVersionRestore } from './backend/useVersionRestore'; schemaLibraryItem,
export { EditorLibraryItem } from './components/EditorLibraryItem'; schemaUpdateLibraryItem,
export { MiniSelectorOSS } from './components/MiniSelectorOSS'; schemaVersionInfo
export { PickSchema } from './components/PickSchema'; } from './backend/types';
export { SelectLibraryItem } from './components/SelectLibraryItem'; export { BASIC_SCHEMAS, type CurrentVersion, type ILibraryItemReference, LocationHead } from './models/library';
export { SelectVersion } from './components/SelectVersion';
export { ToolbarItemAccess } from './components/ToolbarItemAccess';
export { type ILibraryItemReference } from './models/library';
export { useLibrarySearchStore } from './stores/librarySearch'; export { useLibrarySearchStore } from './stores/librarySearch';

View File

@ -1,4 +1,4 @@
import { RequireAuth } from '@/features/auth'; import { RequireAuth } from '@/features/auth/components';
import { FormCreateItem } from './FormCreateItem'; import { FormCreateItem } from './FormCreateItem';

View File

@ -10,7 +10,6 @@ import { useAuthSuspense } from '@/features/auth';
import { Overlay } from '@/components/Container'; import { Overlay } from '@/components/Container';
import { Button, MiniButton, SubmitButton } from '@/components/Control'; import { Button, MiniButton, SubmitButton } from '@/components/Control';
import { VisibilityIcon } from '@/components/DomainIcons';
import { IconDownload } from '@/components/Icons'; import { IconDownload } from '@/components/Icons';
import { InfoError } from '@/components/InfoError'; import { InfoError } from '@/components/InfoError';
import { Label, TextArea, TextInput } from '@/components/Input'; import { Label, TextArea, TextInput } from '@/components/Input';
@ -23,6 +22,7 @@ import {
schemaCreateLibraryItem schemaCreateLibraryItem
} from '../../backend/types'; } from '../../backend/types';
import { useCreateItem } from '../../backend/useCreateItem'; import { useCreateItem } from '../../backend/useCreateItem';
import { IconItemVisibility } from '../../components/IconItemVisibility';
import { SelectAccessPolicy } from '../../components/SelectAccessPolicy'; import { SelectAccessPolicy } from '../../components/SelectAccessPolicy';
import { SelectItemType } from '../../components/SelectItemType'; import { SelectItemType } from '../../components/SelectItemType';
import { SelectLocationContext } from '../../components/SelectLocationContext'; import { SelectLocationContext } from '../../components/SelectLocationContext';
@ -69,14 +69,14 @@ export function FormCreateItem() {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push({ path: urls.library });
} }
} }
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files && event.target.files.length > 0) { if (event.target.files && event.target.files.length > 0) {
setValue('file', event.target.files[0]); setValue('file', event.target.files[0]);
setValue('fileName', event.target.files[0].name); setValue('fileName', event.target.files[0].name, { shouldValidate: true });
} else { } else {
setValue('file', undefined); setValue('file', undefined);
setValue('fileName', ''); setValue('fileName', '');
@ -88,16 +88,16 @@ export function FormCreateItem() {
setValue('file', undefined); setValue('file', undefined);
setValue('fileName', ''); setValue('fileName', '');
} }
setValue('item_type', value); setValue('item_type', value, { shouldValidate: true });
} }
function onSubmit(data: ICreateLibraryItemDTO) { function onSubmit(data: ICreateLibraryItemDTO) {
return createItem(data).then(newItem => { return createItem(data).then(newItem => {
setSearchLocation(data.location); setSearchLocation(data.location);
if (newItem.item_type == LibraryItemType.RSFORM) { if (newItem.item_type == LibraryItemType.RSFORM) {
router.push(urls.schema(newItem.id)); router.push({ path: urls.schema(newItem.id), force: true });
} else { } else {
router.push(urls.oss(newItem.id)); router.push({ path: urls.oss(newItem.id), force: true });
} }
}); });
} }
@ -188,7 +188,7 @@ export function FormCreateItem() {
render={({ field }) => ( render={({ field }) => (
<MiniButton <MiniButton
title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'} title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={field.value} />} icon={<IconItemVisibility value={field.value} />}
onClick={() => field.onChange(!field.value)} onClick={() => field.onChange(!field.value)}
/> />
)} )}

View File

@ -44,9 +44,9 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
return; return;
} }
if (item.item_type === LibraryItemType.RSFORM) { if (item.item_type === LibraryItemType.RSFORM) {
router.push(urls.schema(item.id), event.ctrlKey || event.metaKey); router.push({ path: urls.schema(item.id), newTab: event.ctrlKey || event.metaKey });
} else if (item.item_type === LibraryItemType.OSS) { } else if (item.item_type === LibraryItemType.OSS) {
router.push(urls.oss(item.id), event.ctrlKey || event.metaKey); router.push({ path: urls.oss(item.id), newTab: event.ctrlKey || event.metaKey });
} }
} }

View File

@ -2,10 +2,9 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { SelectUser } from '@/features/users'; import { SelectUser } from '@/features/users/components';
import { MiniButton, SelectorButton } from '@/components/Control'; import { MiniButton, SelectorButton } from '@/components/Control';
import { LocationIcon, VisibilityIcon } from '@/components/DomainIcons';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown'; import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { import {
IconEditor, IconEditor,
@ -20,6 +19,8 @@ import { SearchBar } from '@/components/Input';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import { tripleToggleColor } from '@/utils/utils'; import { tripleToggleColor } from '@/utils/utils';
import { IconItemVisibility } from '../../components/IconItemVisibility';
import { IconLocationHead } from '../../components/IconLocationHead';
import { describeLocationHead, labelLocationHead } from '../../labels'; import { describeLocationHead, labelLocationHead } from '../../labels';
import { LocationHead } from '../../models/library'; import { LocationHead } from '../../models/library';
import { useHasCustomFilter, useLibrarySearchStore } from '../../stores/librarySearch'; import { useHasCustomFilter, useLibrarySearchStore } from '../../stores/librarySearch';
@ -98,7 +99,7 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
<div className='cc-icons'> <div className='cc-icons'>
<MiniButton <MiniButton
title='Видимость' title='Видимость'
icon={<VisibilityIcon value={true} className={tripleToggleColor(isVisible)} />} icon={<IconItemVisibility value={true} className={tripleToggleColor(isVisible)} />}
onClick={toggleVisible} onClick={toggleVisible}
/> />
@ -156,7 +157,7 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
hideTitle={headMenu.isOpen} hideTitle={headMenu.isOpen}
icon={ icon={
head ? ( head ? (
<LocationIcon value={head} size='1.25rem' /> <IconLocationHead value={head} size='1.25rem' />
) : ( ) : (
<IconFolderSearch size='1.25rem' className='clr-text-controls' /> <IconFolderSearch size='1.25rem' className='clr-text-controls' />
) )
@ -165,7 +166,7 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
text={head ?? '//'} text={head ?? '//'}
/> />
<Dropdown isOpen={headMenu.isOpen} stretchLeft className='z-modalTooltip'> <Dropdown isOpen={headMenu.isOpen} stretchLeft className='z-modal-tooltip'>
<DropdownButton title='Переключение в режим Проводник' onClick={handleToggleFolder}> <DropdownButton title='Переключение в режим Проводник' onClick={handleToggleFolder}>
<div className='inline-flex items-center gap-3'> <div className='inline-flex items-center gap-3'>
<IconFolderTree size='1rem' className='clr-text-controls' /> <IconFolderTree size='1rem' className='clr-text-controls' />
@ -187,7 +188,7 @@ export function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
title={describeLocationHead(head)} title={describeLocationHead(head)}
> >
<div className='inline-flex items-center gap-3'> <div className='inline-flex items-center gap-3'>
<LocationIcon value={head} size='1rem' /> <IconLocationHead value={head} size='1rem' />
{labelLocationHead(head)} {labelLocationHead(head)}
</div> </div>
</DropdownButton> </DropdownButton>

View File

@ -2,10 +2,10 @@ import { toast } from 'react-toastify';
import clsx from 'clsx'; import clsx from 'clsx';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { BadgeHelp, HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { SubfoldersIcon } from '@/components/DomainIcons';
import { IconFolderEdit, IconFolderTree } from '@/components/Icons'; import { IconFolderEdit, IconFolderTree } from '@/components/Icons';
import { useWindowSize } from '@/hooks/useWindowSize'; import { useWindowSize } from '@/hooks/useWindowSize';
import { useFitHeight } from '@/stores/appLayout'; import { useFitHeight } from '@/stores/appLayout';
@ -13,6 +13,7 @@ import { PARAMETER, prefixes } from '@/utils/constants';
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { useLibrary } from '../../backend/useLibrary'; import { useLibrary } from '../../backend/useLibrary';
import { IconShowSubfolders } from '../../components/IconShowSubfolders';
import { SelectLocation } from '../../components/SelectLocation'; import { SelectLocation } from '../../components/SelectLocation';
import { type FolderNode } from '../../models/FolderTree'; import { type FolderNode } from '../../models/FolderTree';
import { useLibrarySearchStore } from '../../stores/librarySearch'; import { useLibrarySearchStore } from '../../stores/librarySearch';
@ -90,7 +91,7 @@ export function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocati
{!!location ? ( {!!location ? (
<MiniButton <MiniButton
title={subfolders ? 'Вложенные папки: Вкл' : 'Вложенные папки: Выкл'} // prettier: split-lines title={subfolders ? 'Вложенные папки: Вкл' : 'Вложенные папки: Выкл'} // prettier: split-lines
icon={<SubfoldersIcon value={subfolders} />} icon={<IconShowSubfolders value={subfolders} />}
onClick={toggleSubfolders} onClick={toggleSubfolders}
/> />
) : null} ) : null}

View File

@ -2,7 +2,7 @@
* Module: OSS data loading and processing. * Module: OSS data loading and processing.
*/ */
import { type ILibraryItem } from '@/features/library/backend/types'; import { type ILibraryItem } from '@/features/library';
import { Graph } from '@/models/Graph'; import { Graph } from '@/models/Graph';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library'; import { useUpdateTimestamp } from '@/features/library/backend/useUpdateTimestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type ILibraryItem } from '@/features/library/backend/types'; import { type ILibraryItem } from '@/features/library';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library'; import { useUpdateTimestamp } from '@/features/library/backend/useUpdateTimestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -0,0 +1,10 @@
import { type DomIconProps, IconMoveDown, IconMoveUp } from '@/components/Icons';
/** Icon for relocation direction. */
export function IconRelocationUp({ value, size = '1.25rem', className }: DomIconProps<boolean>) {
if (value) {
return <IconMoveUp size={size} className={className ?? 'text-sec-600'} />;
} else {
return <IconMoveDown size={size} className={className ?? 'text-sec-600'} />;
}
}

View File

@ -0,0 +1,20 @@
import { Tooltip } from '@/components/Container';
import { globalIDs } from '@/utils/constants';
import { useOperationTooltipStore } from '../stores/operationTooltip';
import { InfoOperation } from './InfoOperation';
export function OperationTooltip() {
const hoverOperation = useOperationTooltipStore(state => state.activeOperation);
return (
<Tooltip
id={globalIDs.operation_tooltip}
layer='z-topmost'
className='max-w-[35rem] max-h-[40rem] dense'
hidden={!hoverOperation}
>
{hoverOperation ? <InfoOperation operation={hoverOperation} /> : null}
</Tooltip>
);
}

View File

@ -4,7 +4,9 @@ import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx'; import clsx from 'clsx';
import { type ILibraryItem, LibraryItemType, PickSchema, useLibrary } from '@/features/library'; import { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/useLibrary';
import { PickSchema } from '@/features/library/components';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconReset } from '@/components/Icons'; import { IconReset } from '@/components/Icons';

View File

@ -2,7 +2,9 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { type ILibraryItem, LibraryItemType, PickSchema, useLibrary } from '@/features/library'; import { type ILibraryItem, LibraryItemType } from '@/features/library';
import { useLibrary } from '@/features/library/backend/useLibrary';
import { PickSchema } from '@/features/library/components';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { IconReset } from '@/components/Icons'; import { IconReset } from '@/components/Icons';
@ -47,7 +49,7 @@ export function TabInputOperation() {
setValue('create_schema', false); setValue('create_schema', false);
setValue('item_data.alias', schema.alias); setValue('item_data.alias', schema.alias);
setValue('item_data.title', schema.title); setValue('item_data.title', schema.title);
setValue('item_data.comment', schema.comment); setValue('item_data.comment', schema.comment, { shouldValidate: true });
} }
return ( return (

View File

@ -47,7 +47,7 @@ export function DlgEditOperation() {
target: target.id, target: target.id,
item_data: { item_data: {
alias: target.alias, alias: target.alias,
title: target.alias, title: target.title,
comment: target.comment comment: target.comment
}, },
arguments: target.arguments, arguments: target.arguments,

View File

@ -17,7 +17,7 @@ export function TabArguments() {
const filtered = oss.items.filter(item => !potentialCycle.includes(item.id)); const filtered = oss.items.filter(item => !potentialCycle.includes(item.id));
function handleChangeArguments(prev: number[], newValue: number[]) { function handleChangeArguments(prev: number[], newValue: number[]) {
setValue('arguments', newValue); setValue('arguments', newValue, { shouldValidate: true });
if (prev.some(id => !newValue.includes(id))) { if (prev.some(id => !newValue.includes(id))) {
setValue('substitutions', []); setValue('substitutions', []);
} }

View File

@ -2,7 +2,8 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { PickSubstitutions, useRSForms } from '@/features/rsform'; import { useRSForms } from '@/features/rsform/backend/useRSForms';
import { PickSubstitutions } from '@/features/rsform/components';
import { TextArea } from '@/components/Input'; import { TextArea } from '@/components/Input';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';

View File

@ -6,11 +6,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx'; import clsx from 'clsx';
import { HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { type ILibraryItem, SelectLibraryItem, useLibrary } from '@/features/library'; import { type ILibraryItem } from '@/features/library';
import { PickMultiConstituenta, useRSForm } from '@/features/rsform'; import { useLibrary } from '@/features/library/backend/useLibrary';
import { SelectLibraryItem } from '@/features/library/components';
import { useRSForm } from '@/features/rsform/backend/useRSForm';
import { PickMultiConstituenta } from '@/features/rsform/components';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { RelocateUpIcon } from '@/components/DomainIcons';
import { Loader } from '@/components/Loader'; import { Loader } from '@/components/Loader';
import { ModalForm } from '@/components/Modal'; import { ModalForm } from '@/components/Modal';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
@ -18,6 +20,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { type ICstRelocateDTO, type IOperationPosition, schemaCstRelocate } from '../backend/types'; import { type ICstRelocateDTO, type IOperationPosition, schemaCstRelocate } from '../backend/types';
import { useRelocateConstituents } from '../backend/useRelocateConstituents'; import { useRelocateConstituents } from '../backend/useRelocateConstituents';
import { useUpdatePositions } from '../backend/useUpdatePositions'; import { useUpdatePositions } from '../backend/useUpdatePositions';
import { IconRelocationUp } from '../components/IconRelocationUp';
import { type IOperation, type IOperationSchema } from '../models/oss'; import { type IOperation, type IOperationSchema } from '../models/oss';
import { getRelocateCandidates } from '../models/ossAPI'; import { getRelocateCandidates } from '../models/ossAPI';
@ -132,7 +135,7 @@ export function DlgRelocateConstituents() {
/> />
<MiniButton <MiniButton
title='Направление перемещения' title='Направление перемещения'
icon={<RelocateUpIcon value={directionUp} />} icon={<IconRelocationUp value={directionUp} />}
onClick={toggleDirection} onClick={toggleDirection}
/> />
<SelectLibraryItem <SelectLibraryItem

View File

@ -1 +1,2 @@
export { useFindPredecessor } from './backend/useFindPredecessor'; export { type IOperationSchemaDTO } from './backend/types';
export { type IOperation } from './models/oss';

View File

@ -2,10 +2,16 @@
* Module: API for OperationSystem. * Module: API for OperationSystem.
*/ */
import { type ILibraryItem } from '@/features/library/backend/types'; import { type ILibraryItem } from '@/features/library';
import { CstType, type ICstSubstitute, ParsingStatus } from '@/features/rsform/backend/types'; import {
import { CstClass, type IConstituenta, type IRSForm } from '@/features/rsform/models/rsform'; type AliasMapping,
import { type AliasMapping } from '@/features/rsform/models/rslang'; CstClass,
CstType,
type IConstituenta,
type ICstSubstitute,
type IRSForm,
ParsingStatus
} from '@/features/rsform';
import { import {
applyAliasMapping, applyAliasMapping,
applyTypificationMapping, applyTypificationMapping,
@ -13,7 +19,6 @@ import {
isSetTypification isSetTypification
} from '@/features/rsform/models/rslangAPI'; } from '@/features/rsform/models/rslangAPI';
import { limits, PARAMETER } from '@/utils/constants';
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { TextMatcher } from '@/utils/utils'; import { TextMatcher } from '@/utils/utils';
@ -24,6 +29,13 @@ import { describeSubstitutionError } from '../labels';
import { type IOperation, type IOperationSchema, SubstitutionErrorType } from './oss'; import { type IOperation, type IOperationSchema, SubstitutionErrorType } from './oss';
import { type Position2D } from './ossLayout'; import { type Position2D } from './ossLayout';
export const GRID_SIZE = 10; // pixels - size of OSS grid
const MIN_DISTANCE = 20; // pixels - minimum distance between node centers
const DISTANCE_X = 180; // pixels - insert x-distance between node centers
const DISTANCE_Y = 100; // pixels - insert y-distance between node centers
const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution
/** /**
* Checks if a given target {@link IOperation} matches the specified query using. * Checks if a given target {@link IOperation} matches the specified query using.
* *
@ -92,7 +104,7 @@ export class SubstitutionValidator {
this.schemaByCst.set(item.id, schema); this.schemaByCst.set(item.id, schema);
}); });
}); });
let index = limits.max_semantic_index; let index = STARTING_SUB_INDEX;
substitutions.forEach(item => { substitutions.forEach(item => {
this.constituents.add(item.original); this.constituents.add(item.original);
this.constituents.add(item.substitution); this.constituents.add(item.substitution);
@ -500,27 +512,27 @@ export function calculateInsertPosition(
} }
const maxX = Math.max(...inputsNodes.map(node => node.position_x)); const maxX = Math.max(...inputsNodes.map(node => node.position_x));
const minY = Math.min(...inputsNodes.map(node => node.position_y)); const minY = Math.min(...inputsNodes.map(node => node.position_y));
result.x = maxX + PARAMETER.ossDistanceX; result.x = maxX + DISTANCE_X;
result.y = minY; result.y = minY;
} else { } else {
const argNodes = positions.filter(pos => argumentsOps.includes(pos.id)); const argNodes = positions.filter(pos => argumentsOps.includes(pos.id));
const maxY = Math.max(...argNodes.map(node => node.position_y)); const maxY = Math.max(...argNodes.map(node => node.position_y));
const minX = Math.min(...argNodes.map(node => node.position_x)); const minX = Math.min(...argNodes.map(node => node.position_x));
const maxX = Math.max(...argNodes.map(node => node.position_x)); const maxX = Math.max(...argNodes.map(node => node.position_x));
result.x = Math.ceil((maxX + minX) / 2 / PARAMETER.ossGridSize) * PARAMETER.ossGridSize; result.x = Math.ceil((maxX + minX) / 2 / GRID_SIZE) * GRID_SIZE;
result.y = maxY + PARAMETER.ossDistanceY; result.y = maxY + DISTANCE_Y;
} }
let flagIntersect = false; let flagIntersect = false;
do { do {
flagIntersect = positions.some( flagIntersect = positions.some(
position => position =>
Math.abs(position.position_x - result.x) < PARAMETER.ossMinDistance && Math.abs(position.position_x - result.x) < MIN_DISTANCE &&
Math.abs(position.position_y - result.y) < PARAMETER.ossMinDistance Math.abs(position.position_y - result.y) < MIN_DISTANCE
); );
if (flagIntersect) { if (flagIntersect) {
result.x += PARAMETER.ossMinDistance; result.x += MIN_DISTANCE;
result.y += PARAMETER.ossMinDistance; result.y += MIN_DISTANCE;
} }
} while (flagIntersect); } while (flagIntersect);
return result; return result;

View File

@ -2,8 +2,8 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { EditorLibraryItem } from '@/features/library'; import { EditorLibraryItem } from '@/features/library/components';
import { ToolbarRSFormCard } from '@/features/rsform'; import { ToolbarRSFormCard } from '@/features/rsform/components';
import { FlexColumn } from '@/components/Container'; import { FlexColumn } from '@/components/Container';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';

View File

@ -6,8 +6,9 @@ import { 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';
import { LibraryItemType, ToolbarItemAccess, useUpdateItem } from '@/features/library'; import { type IUpdateLibraryItemDTO, LibraryItemType, schemaUpdateLibraryItem } from '@/features/library';
import { type IUpdateLibraryItemDTO, schemaUpdateLibraryItem } from '@/features/library/backend/types'; import { useUpdateItem } from '@/features/library/backend/useUpdateItem';
import { ToolbarItemAccess } from '@/features/library/components';
import { SubmitButton } from '@/components/Control'; import { SubmitButton } from '@/components/Control';
import { IconSave } from '@/components/Icons'; import { IconSave } from '@/components/Icons';

View File

@ -1,6 +1,12 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import { toast } from 'react-toastify';
import { urls, useConceptNavigation } from '@/app';
import { useLibrary } from '@/features/library/backend/useLibrary';
import { useInputCreate } from '@/features/oss/backend/useInputCreate';
import { useOperationExecute } from '@/features/oss/backend/useOperationExecute';
import { Dropdown, DropdownButton } from '@/components/Dropdown'; import { Dropdown, DropdownButton } from '@/components/Dropdown';
import { import {
@ -13,7 +19,8 @@ import {
IconRSForm IconRSForm
} from '@/components/Icons'; } from '@/components/Icons';
import { useClickedOutside } from '@/hooks/useClickedOutside'; import { useClickedOutside } from '@/hooks/useClickedOutside';
import { PARAMETER } from '@/utils/constants'; import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { OperationType } from '../../../backend/types'; import { OperationType } from '../../../backend/types';
@ -21,8 +28,14 @@ import { useMutatingOss } from '../../../backend/useMutatingOss';
import { type IOperation } from '../../../models/oss'; import { type IOperation } from '../../../models/oss';
import { useOssEdit } from '../OssEditContext'; import { useOssEdit } from '../OssEditContext';
import { useGetPositions } from './useGetPositions';
// pixels - size of OSS context menu
const MENU_WIDTH = 200;
const MENU_HEIGHT = 200;
export interface ContextMenuData { export interface ContextMenuData {
operation: IOperation; operation: IOperation | null;
cursorX: number; cursorX: number;
cursorY: number; cursorY: number;
} }
@ -30,33 +43,25 @@ export interface ContextMenuData {
interface NodeContextMenuProps extends ContextMenuData { interface NodeContextMenuProps extends ContextMenuData {
isOpen: boolean; isOpen: boolean;
onHide: () => void; onHide: () => void;
onDelete: (target: number) => void;
onCreateInput: (target: number) => void;
onEditSchema: (target: number) => void;
onEditOperation: (target: number) => void;
onExecuteOperation: (target: number) => void;
onRelocateConstituents: (target: number) => void;
} }
export function NodeContextMenu({ export function NodeContextMenu({ isOpen, operation, cursorX, cursorY, onHide }: NodeContextMenuProps) {
isOpen, const router = useConceptNavigation();
operation, const { items: libraryItems } = useLibrary();
cursorX, const { schema, navigateOperationSchema, isMutable, canDeleteOperation: canDelete } = useOssEdit();
cursorY,
onHide,
onDelete,
onCreateInput,
onEditSchema,
onEditOperation,
onExecuteOperation,
onRelocateConstituents
}: NodeContextMenuProps) {
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { schema, navigateOperationSchema, isMutable, canDelete } = useOssEdit(); const getPositions = useGetPositions();
const { inputCreate } = useInputCreate();
const { operationExecute } = useOperationExecute();
const showEditInput = useDialogsStore(state => state.showChangeInputSchema);
const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents);
const showEditOperation = useDialogsStore(state => state.showEditOperation);
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
const ref = useRef<HTMLDivElement>(null);
const readyForSynthesis = (() => { const readyForSynthesis = (() => {
if (operation.operation_type !== OperationType.SYNTHESIS) { if (operation?.operation_type !== OperationType.SYNTHESIS) {
return false; return false;
} }
if (operation.result) { if (operation.result) {
@ -76,48 +81,97 @@ export function NodeContextMenu({
return true; return true;
})(); })();
const ref = useRef<HTMLDivElement>(null);
useClickedOutside(isOpen, ref, onHide); useClickedOutside(isOpen, ref, onHide);
function handleOpenSchema() { function handleOpenSchema() {
if (!operation) {
return;
}
onHide();
navigateOperationSchema(operation.id); navigateOperationSchema(operation.id);
} }
function handleEditSchema() { function handleEditSchema() {
if (!operation) {
return;
}
onHide(); onHide();
onEditSchema(operation.id); showEditInput({
oss: schema,
target: operation,
positions: getPositions()
});
} }
function handleEditOperation() { function handleEditOperation() {
if (!operation) {
return;
}
onHide(); onHide();
onEditOperation(operation.id); showEditOperation({
oss: schema,
target: operation,
positions: getPositions()
});
} }
function handleDeleteOperation() { function handleDeleteOperation() {
if (!operation || !canDelete(operation)) {
return;
}
onHide(); onHide();
onDelete(operation.id); showDeleteOperation({
oss: schema,
target: operation,
positions: getPositions()
});
} }
function handleCreateSchema() { function handleOperationExecute() {
if (!operation) {
return;
}
onHide(); onHide();
onCreateInput(operation.id); void operationExecute({
itemID: schema.id, //
data: { target: operation.id, positions: getPositions() }
});
} }
function handleRunSynthesis() { function handleInputCreate() {
if (!operation) {
return;
}
if (libraryItems.find(item => item.alias === operation.alias && item.location === schema.location)) {
toast.error(errorMsg.inputAlreadyExists);
return;
}
onHide(); onHide();
onExecuteOperation(operation.id); void inputCreate({
itemID: schema.id,
data: { target: operation.id, positions: getPositions() }
}).then(new_schema => router.push({ path: urls.schema(new_schema.id), force: true }));
} }
function handleRelocateConstituents() { function handleRelocateConstituents() {
if (!operation) {
return;
}
onHide(); onHide();
onRelocateConstituents(operation.id); showRelocateConstituents({
oss: schema,
initialTarget: operation,
positions: getPositions()
});
} }
return ( return (
<div ref={ref} className='absolute select-none' style={{ top: cursorY, left: cursorX }}> <div ref={ref} className='absolute select-none' style={{ top: cursorY, left: cursorX }}>
<Dropdown <Dropdown
isOpen={isOpen} isOpen={isOpen}
stretchLeft={cursorX >= window.innerWidth - PARAMETER.ossContextMenuWidth} stretchLeft={cursorX >= window.innerWidth - MENU_WIDTH}
stretchTop={cursorY >= window.innerHeight - PARAMETER.ossContextMenuHeight} stretchTop={cursorY >= window.innerHeight - MENU_HEIGHT}
> >
<DropdownButton <DropdownButton
text='Редактировать' text='Редактировать'
@ -142,7 +196,7 @@ export function NodeContextMenu({
title='Создать пустую схему для загрузки' title='Создать пустую схему для загрузки'
icon={<IconNewRSForm size='1rem' className='icon-green' />} icon={<IconNewRSForm size='1rem' className='icon-green' />}
disabled={isProcessing} disabled={isProcessing}
onClick={handleCreateSchema} onClick={handleInputCreate}
/> />
) : null} ) : null}
{isMutable && operation?.operation_type === OperationType.INPUT ? ( {isMutable && operation?.operation_type === OperationType.INPUT ? (
@ -164,7 +218,7 @@ export function NodeContextMenu({
} }
icon={<IconExecute size='1rem' className='icon-green' />} icon={<IconExecute size='1rem' className='icon-green' />}
disabled={isProcessing || !readyForSynthesis} disabled={isProcessing || !readyForSynthesis}
onClick={handleRunSynthesis} onClick={handleOperationExecute}
/> />
) : null} ) : null}
@ -181,7 +235,7 @@ export function NodeContextMenu({
<DropdownButton <DropdownButton
text='Удалить операцию' text='Удалить операцию'
icon={<IconDestroy size='1rem' className='icon-red' />} icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={!isMutable || isProcessing || !operation || !canDelete(operation.id)} disabled={!isMutable || isProcessing || !operation || !canDelete(operation)}
onClick={handleDeleteOperation} onClick={handleDeleteOperation}
/> />
</Dropdown> </Dropdown>

View File

@ -1,11 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { import {
Background, Background,
getNodesBounds,
getViewportForBounds,
type Node, type Node,
ReactFlow, ReactFlow,
useEdgesState, useEdgesState,
@ -13,32 +10,28 @@ import {
useOnSelectionChange, useOnSelectionChange,
useReactFlow useReactFlow
} from 'reactflow'; } from 'reactflow';
import { toPng } from 'html-to-image';
import { urls, useConceptNavigation } from '@/app';
import { useLibrary } from '@/features/library';
import { Overlay } from '@/components/Container'; import { Overlay } from '@/components/Container';
import { useMainHeight } from '@/stores/appLayout'; import { useMainHeight } from '@/stores/appLayout';
import { useTooltipsStore } from '@/stores/tooltips'; import { useDialogsStore } from '@/stores/dialogs';
import { APP_COLORS } from '@/styling/colors';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { errorMsg } from '@/utils/labels';
import { useInputCreate } from '../../../backend/useInputCreate';
import { useMutatingOss } from '../../../backend/useMutatingOss'; import { useMutatingOss } from '../../../backend/useMutatingOss';
import { useOperationExecute } from '../../../backend/useOperationExecute';
import { useUpdatePositions } from '../../../backend/useUpdatePositions'; import { useUpdatePositions } from '../../../backend/useUpdatePositions';
import { GRID_SIZE } from '../../../models/ossAPI';
import { type OssNode } from '../../../models/ossLayout'; import { type OssNode } from '../../../models/ossLayout';
import { useOperationTooltipStore } from '../../../stores/operationTooltip';
import { useOSSGraphStore } from '../../../stores/ossGraph'; import { useOSSGraphStore } from '../../../stores/ossGraph';
import { useOssEdit } from '../OssEditContext'; import { useOssEdit } from '../OssEditContext';
import { OssNodeTypes } from './graph/OssNodeTypes'; import { OssNodeTypes } from './graph/OssNodeTypes';
import { type ContextMenuData, NodeContextMenu } from './NodeContextMenu'; import { type ContextMenuData, NodeContextMenu } from './NodeContextMenu';
import { ToolbarOssGraph } from './ToolbarOssGraph'; import { ToolbarOssGraph } from './ToolbarOssGraph';
import { useGetPositions } from './useGetPositions';
const ZOOM_MAX = 2; const ZOOM_MAX = 2;
const ZOOM_MIN = 0.5; const ZOOM_MIN = 0.5;
export const VIEW_PADDING = 0.2;
export function OssFlow() { export function OssFlow() {
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
@ -48,35 +41,30 @@ export function OssFlow() {
setSelected, setSelected,
selected, selected,
isMutable, isMutable,
promptCreateOperation, canDeleteOperation: canDelete
canDelete,
promptDeleteOperation,
promptEditInput,
promptEditOperation,
promptRelocateConstituents
} = useOssEdit(); } = useOssEdit();
const router = useConceptNavigation(); const { fitView, screenToFlowPosition } = useReactFlow();
const { items: libraryItems } = useLibrary();
const flow = useReactFlow();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const setHoverOperation = useTooltipsStore(state => state.setActiveOperation); const setHoverOperation = useOperationTooltipStore(state => state.setActiveOperation);
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate); const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate);
const edgeStraight = useOSSGraphStore(state => state.edgeStraight); const edgeStraight = useOSSGraphStore(state => state.edgeStraight);
const { inputCreate } = useInputCreate(); const getPositions = useGetPositions();
const { operationExecute } = useOperationExecute();
const { updatePositions } = useUpdatePositions(); const { updatePositions } = useUpdatePositions();
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [toggleReset, setToggleReset] = useState(false); const [toggleReset, setToggleReset] = useState(false);
const [menuProps, setMenuProps] = useState<ContextMenuData | null>(null); const [menuProps, setMenuProps] = useState<ContextMenuData>({ operation: null, cursorX: 0, cursorY: 0 });
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const showCreateOperation = useDialogsStore(state => state.showCreateOperation);
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
function onSelectionChange({ nodes }: { nodes: Node[] }) { function onSelectionChange({ nodes }: { nodes: Node[] }) {
const ids = nodes.map(node => Number(node.id)); const ids = nodes.map(node => Number(node.id));
setSelected(prev => [ setSelected(prev => [
@ -112,15 +100,8 @@ export function OssFlow() {
: 'left' : 'left'
})) }))
); );
}, [schema, setNodes, setEdges, toggleReset, edgeStraight, edgeAnimate]); setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout);
}, [schema, setNodes, setEdges, toggleReset, edgeStraight, edgeAnimate, fitView]);
function getPositions() {
return nodes.map(node => ({
id: Number(node.id),
position_x: node.position.x,
position_y: node.position.y
}));
}
function handleSavePositions() { function handleSavePositions() {
const positions = getPositions(); const positions = getPositions();
@ -135,103 +116,31 @@ export function OssFlow() {
}); });
} }
function handleCreateOperation(inputs: number[]) { function handleCreateOperation() {
const positions = getPositions(); const targetPosition = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
const target = flow.project({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); showCreateOperation({
promptCreateOperation({ oss: schema,
defaultX: target.x, defaultX: targetPosition.x,
defaultY: target.y, defaultY: targetPosition.y,
inputs: inputs, positions: getPositions(),
positions: positions, initialInputs: selected,
callback: () => setTimeout(() => flow.fitView({ duration: PARAMETER.zoomDuration }), PARAMETER.refreshTimeout) onCreate: () =>
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout)
}); });
} }
function handleDeleteOperation(target: number) {
if (!canDelete(target)) {
return;
}
promptDeleteOperation(target, getPositions());
}
function handleDeleteSelected() { function handleDeleteSelected() {
if (selected.length !== 1) { if (selected.length !== 1) {
return; return;
} }
handleDeleteOperation(selected[0]); const operation = schema.operationByID.get(selected[0]);
} if (!operation || !canDelete(operation)) {
function handleInputCreate(target: number) {
const operation = schema.operationByID.get(target);
if (!operation) {
return; return;
} }
if (libraryItems.find(item => item.alias === operation.alias && item.location === schema.location)) { showDeleteOperation({
toast.error(errorMsg.inputAlreadyExists); oss: schema,
return; target: operation,
} positions: getPositions()
void inputCreate({
itemID: schema.id,
data: { target: target, positions: getPositions() }
}).then(new_schema => router.push(urls.schema(new_schema.id)));
}
function handleEditSchema(target: number) {
promptEditInput(target, getPositions());
}
function handleEditOperation(target: number) {
promptEditOperation(target, getPositions());
}
function handleOperationExecute(target: number) {
void operationExecute({
itemID: schema.id, //
data: { target: target, positions: getPositions() }
});
}
function handleExecuteSelected() {
if (selected.length !== 1) {
return;
}
handleOperationExecute(selected[0]);
}
function handleRelocateConstituents(target: number) {
promptRelocateConstituents(target, getPositions());
}
function handleSaveImage() {
const canvas: HTMLElement | null = document.querySelector('.react-flow__viewport');
if (canvas === null) {
toast.error(errorMsg.imageFailed);
return;
}
const imageWidth = PARAMETER.ossImageWidth;
const imageHeight = PARAMETER.ossImageHeight;
const nodesBounds = getNodesBounds(nodes);
const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, ZOOM_MIN, ZOOM_MAX);
toPng(canvas, {
backgroundColor: APP_COLORS.bgDefault,
width: imageWidth,
height: imageHeight,
style: {
width: String(imageWidth),
height: String(imageHeight),
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`
}
})
.then(dataURL => {
const a = document.createElement('a');
a.setAttribute('download', `${schema.alias}.png`);
a.setAttribute('href', dataURL);
a.click();
})
.catch(error => {
console.error(error);
toast.error(errorMsg.imageFailed);
}); });
} }
@ -248,14 +157,6 @@ export function OssFlow() {
setHoverOperation(null); setHoverOperation(null);
} }
function handleContextMenuHide() {
setIsContextMenuOpen(false);
}
function handleCanvasClick() {
handleContextMenuHide();
}
function handleNodeDoubleClick(event: React.MouseEvent<Element>, node: OssNode) { function handleNodeDoubleClick(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -265,10 +166,7 @@ export function OssFlow() {
} }
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (isProcessing) { if (isProcessing || !isMutable) {
return;
}
if (!isMutable) {
return; return;
} }
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
@ -280,7 +178,7 @@ export function OssFlow() {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyQ') { if ((event.ctrlKey || event.metaKey) && event.code === 'KeyQ') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleCreateOperation(selected); handleCreateOperation();
return; return;
} }
if (event.key === 'Delete') { if (event.key === 'Delete') {
@ -298,36 +196,20 @@ export function OssFlow() {
className='rounded-b-2xl cc-blur hover:bg-prim-100 hover:bg-opacity-50' className='rounded-b-2xl cc-blur hover:bg-prim-100 hover:bg-opacity-50'
> >
<ToolbarOssGraph <ToolbarOssGraph
onFitView={() => flow.fitView({ duration: PARAMETER.zoomDuration })} onCreate={handleCreateOperation}
onCreate={() => handleCreateOperation(selected)}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onEdit={() => handleEditOperation(selected[0])}
onExecute={handleExecuteSelected}
onResetPositions={() => setToggleReset(prev => !prev)} onResetPositions={() => setToggleReset(prev => !prev)}
onSavePositions={handleSavePositions}
onSaveImage={handleSaveImage}
/> />
</Overlay> </Overlay>
{menuProps ? (
<NodeContextMenu <NodeContextMenu isOpen={isContextMenuOpen} onHide={() => setIsContextMenuOpen(false)} {...menuProps} />
isOpen={isContextMenuOpen}
onHide={handleContextMenuHide}
onDelete={handleDeleteOperation}
onCreateInput={handleInputCreate}
onEditSchema={handleEditSchema}
onEditOperation={handleEditOperation}
onExecuteOperation={handleOperationExecute}
onRelocateConstituents={handleRelocateConstituents}
{...menuProps}
/>
) : null}
<div className='cc-fade-in relative w-[100vw]' style={{ height: mainHeight, fontFamily: 'Rubik' }}> <div className='cc-fade-in relative w-[100vw]' style={{ height: mainHeight, fontFamily: 'Rubik' }}>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeDoubleClick={handleNodeDoubleClick}
edgesFocusable={false} edgesFocusable={false}
nodesFocusable={false} nodesFocusable={false}
fitView fitView
@ -336,11 +218,13 @@ export function OssFlow() {
minZoom={ZOOM_MIN} minZoom={ZOOM_MIN}
nodesConnectable={false} nodesConnectable={false}
snapToGrid={true} snapToGrid={true}
snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]} snapGrid={[GRID_SIZE, GRID_SIZE]}
onClick={() => setIsContextMenuOpen(false)}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleContextMenu} onNodeContextMenu={handleContextMenu}
onClick={handleCanvasClick} onNodeDragStart={() => setIsContextMenuOpen(false)}
> >
{showGrid ? <Background gap={PARAMETER.ossGridSize} /> : null} {showGrid ? <Background gap={GRID_SIZE} /> : null}
</ReactFlow> </ReactFlow>
</div> </div>
</div> </div>

View File

@ -1,8 +1,12 @@
'use client'; 'use client';
import { useReactFlow } from 'reactflow';
import clsx from 'clsx'; import clsx from 'clsx';
import { BadgeHelp, HelpTopic } from '@/features/help'; import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
import { useOperationExecute } from '@/features/oss/backend/useOperationExecute';
import { useUpdatePositions } from '@/features/oss/backend/useUpdatePositions';
import { MiniButton } from '@/components/Control'; import { MiniButton } from '@/components/Control';
import { import {
@ -13,13 +17,13 @@ import {
IconExecute, IconExecute,
IconFitImage, IconFitImage,
IconGrid, IconGrid,
IconImage,
IconLineStraight, IconLineStraight,
IconLineWave, IconLineWave,
IconNewItem, IconNewItem,
IconReset, IconReset,
IconSave IconSave
} from '@/components/Icons'; } from '@/components/Icons';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
@ -28,30 +32,21 @@ import { useMutatingOss } from '../../../backend/useMutatingOss';
import { useOSSGraphStore } from '../../../stores/ossGraph'; import { useOSSGraphStore } from '../../../stores/ossGraph';
import { useOssEdit } from '../OssEditContext'; import { useOssEdit } from '../OssEditContext';
import { VIEW_PADDING } from './OssFlow';
import { useGetPositions } from './useGetPositions';
interface ToolbarOssGraphProps { interface ToolbarOssGraphProps {
onCreate: () => void; onCreate: () => void;
onDelete: () => void; onDelete: () => void;
onEdit: () => void;
onExecute: () => void;
onFitView: () => void;
onSaveImage: () => void;
onSavePositions: () => void;
onResetPositions: () => void; onResetPositions: () => void;
} }
export function ToolbarOssGraph({ export function ToolbarOssGraph({ onCreate, onDelete, onResetPositions }: ToolbarOssGraphProps) {
onCreate, const { schema, selected, isMutable, canDeleteOperation: canDelete } = useOssEdit();
onDelete,
onEdit,
onExecute,
onFitView,
onSaveImage,
onSavePositions,
onResetPositions
}: ToolbarOssGraphProps) {
const { schema, selected, isMutable, canDelete } = useOssEdit();
const isProcessing = useMutatingOss(); const isProcessing = useMutatingOss();
const { fitView } = useReactFlow();
const selectedOperation = schema.operationByID.get(selected[0]); const selectedOperation = schema.operationByID.get(selected[0]);
const getPositions = useGetPositions();
const showGrid = useOSSGraphStore(state => state.showGrid); const showGrid = useOSSGraphStore(state => state.showGrid);
const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate); const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate);
@ -60,6 +55,11 @@ export function ToolbarOssGraph({
const toggleEdgeAnimate = useOSSGraphStore(state => state.toggleEdgeAnimate); const toggleEdgeAnimate = useOSSGraphStore(state => state.toggleEdgeAnimate);
const toggleEdgeStraight = useOSSGraphStore(state => state.toggleEdgeStraight); const toggleEdgeStraight = useOSSGraphStore(state => state.toggleEdgeStraight);
const { updatePositions } = useUpdatePositions();
const { operationExecute } = useOperationExecute();
const showEditOperation = useDialogsStore(state => state.showEditOperation);
const readyForSynthesis = (() => { const readyForSynthesis = (() => {
if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) { if (!selectedOperation || selectedOperation.operation_type !== OperationType.SYNTHESIS) {
return false; return false;
@ -81,6 +81,44 @@ export function ToolbarOssGraph({
return true; return true;
})(); })();
function handleFitView() {
fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING });
}
function handleSavePositions() {
const positions = getPositions();
void updatePositions({ itemID: schema.id, positions: positions }).then(() => {
positions.forEach(item => {
const operation = schema.operationByID.get(item.id);
if (operation) {
operation.position_x = item.position_x;
operation.position_y = item.position_y;
}
});
});
}
function handleOperationExecute() {
if (selected.length !== 1 || !readyForSynthesis || !selectedOperation) {
return;
}
void operationExecute({
itemID: schema.id, //
data: { target: selectedOperation.id, positions: getPositions() }
});
}
function handleEditOperation() {
if (selected.length !== 1 || !selectedOperation) {
return;
}
showEditOperation({
oss: schema,
target: selectedOperation,
positions: getPositions()
});
}
return ( return (
<div className='flex flex-col items-center'> <div className='flex flex-col items-center'>
<div className='cc-icons'> <div className='cc-icons'>
@ -92,7 +130,7 @@ export function ToolbarOssGraph({
<MiniButton <MiniButton
icon={<IconFitImage size='1.25rem' className='icon-primary' />} icon={<IconFitImage size='1.25rem' className='icon-primary' />}
title='Сбросить вид' title='Сбросить вид'
onClick={onFitView} onClick={handleFitView}
/> />
<MiniButton <MiniButton
title={showGrid ? 'Скрыть сетку' : 'Отобразить сетку'} title={showGrid ? 'Скрыть сетку' : 'Отобразить сетку'}
@ -127,11 +165,6 @@ export function ToolbarOssGraph({
} }
onClick={toggleEdgeAnimate} onClick={toggleEdgeAnimate}
/> />
<MiniButton
icon={<IconImage size='1.25rem' className='icon-primary' />}
title='Сохранить изображение'
onClick={onSaveImage}
/>
<BadgeHelp <BadgeHelp
topic={HelpTopic.UI_OSS_GRAPH} topic={HelpTopic.UI_OSS_GRAPH}
className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')} className={clsx(PARAMETER.TOOLTIP_WIDTH, 'sm:max-w-[40rem]')}
@ -144,7 +177,7 @@ export function ToolbarOssGraph({
titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')} titleHtml={prepareTooltip('Сохранить изменения', 'Ctrl + S')}
icon={<IconSave size='1.25rem' className='icon-primary' />} icon={<IconSave size='1.25rem' className='icon-primary' />}
disabled={isProcessing} disabled={isProcessing}
onClick={onSavePositions} onClick={handleSavePositions}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')} titleHtml={prepareTooltip('Новая операция', 'Ctrl + Q')}
@ -156,18 +189,18 @@ export function ToolbarOssGraph({
title='Активировать операцию' title='Активировать операцию'
icon={<IconExecute size='1.25rem' className='icon-green' />} icon={<IconExecute size='1.25rem' className='icon-green' />}
disabled={isProcessing || selected.length !== 1 || !readyForSynthesis} disabled={isProcessing || selected.length !== 1 || !readyForSynthesis}
onClick={onExecute} onClick={handleOperationExecute}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')} titleHtml={prepareTooltip('Редактировать выбранную', 'Двойной клик')}
icon={<IconEdit2 size='1.25rem' className='icon-primary' />} icon={<IconEdit2 size='1.25rem' className='icon-primary' />}
disabled={selected.length !== 1 || isProcessing} disabled={selected.length !== 1 || isProcessing}
onClick={onEdit} onClick={handleEditOperation}
/> />
<MiniButton <MiniButton
titleHtml={prepareTooltip('Удалить выбранную', 'Delete')} titleHtml={prepareTooltip('Удалить выбранную', 'Delete')}
icon={<IconDestroy size='1.25rem' className='icon-red' />} icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={selected.length !== 1 || isProcessing || !canDelete(selected[0])} disabled={selected.length !== 1 || isProcessing || !selectedOperation || !canDelete(selectedOperation)}
onClick={onDelete} onClick={onDelete}
/> />
</div> </div>

View File

@ -3,23 +3,24 @@
import { Overlay } from '@/components/Container'; import { Overlay } from '@/components/Container';
import { IconConsolidation, IconRSForm } from '@/components/Icons'; import { IconConsolidation, IconRSForm } from '@/components/Icons';
import { Indicator } from '@/components/View'; import { Indicator } from '@/components/View';
import { useTooltipsStore } from '@/stores/tooltips'; import { globalIDs } from '@/utils/constants';
import { globalIDs, PARAMETER } from '@/utils/constants';
import { truncateToLastWord } from '@/utils/utils';
import { OperationType } from '../../../../backend/types'; import { OperationType } from '../../../../backend/types';
import { type OssNodeInternal } from '../../../../models/ossLayout'; import { type OssNodeInternal } from '../../../../models/ossLayout';
import { useOperationTooltipStore } from '../../../../stores/operationTooltip';
// characters - threshold for long labels - small font
const LONG_LABEL_CHARS = 14;
interface NodeCoreProps { interface NodeCoreProps {
node: OssNodeInternal; node: OssNodeInternal;
} }
export function NodeCore({ node }: NodeCoreProps) { export function NodeCore({ node }: NodeCoreProps) {
const setHover = useTooltipsStore(state => state.setActiveOperation); const setHover = useOperationTooltipStore(state => state.setActiveOperation);
const hasFile = !!node.data.operation.result; const hasFile = !!node.data.operation.result;
const longLabel = node.data.label.length > PARAMETER.ossLongLabel; const longLabel = node.data.label.length > LONG_LABEL_CHARS;
const labelText = truncateToLastWord(node.data.label, PARAMETER.ossTruncateLabel);
return ( return (
<> <>
@ -53,10 +54,11 @@ export function NodeCore({ node }: NodeCoreProps) {
<div <div
className='h-[34px] w-[144px] flex items-center justify-center' className='h-[34px] w-[144px] flex items-center justify-center'
data-tooltip-id={globalIDs.operation_tooltip} data-tooltip-id={globalIDs.operation_tooltip}
data-tooltip-hidden={node.dragging}
onMouseEnter={() => setHover(node.data.operation)} onMouseEnter={() => setHover(node.data.operation)}
> >
<div <div
className='text-center' className='text-center line-clamp-2'
style={{ style={{
fontSize: longLabel ? '12px' : '14px', fontSize: longLabel ? '12px' : '14px',
lineHeight: longLabel ? '16px' : '20px', lineHeight: longLabel ? '16px' : '20px',
@ -64,7 +66,7 @@ export function NodeCore({ node }: NodeCoreProps) {
paddingRight: longLabel ? '10px' : '4px' paddingRight: longLabel ? '10px' : '4px'
}} }}
> >
{labelText} {node.data.label}
</div> </div>
</div> </div>
</> </>

View File

@ -0,0 +1,12 @@
import { useReactFlow } from 'reactflow';
export function useGetPositions() {
const { getNodes } = useReactFlow();
return function getPositions() {
return getNodes().map(node => ({
id: Number(node.id),
position_x: node.position.x,
position_y: node.position.y
}));
};
}

View File

@ -0,0 +1,57 @@
import { useAuthSuspense } from '@/features/auth';
import { Button } from '@/components/Control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { IconChild, IconEdit2 } from '@/components/Icons';
import { useDialogsStore } from '@/stores/dialogs';
import { useMutatingOss } from '../../backend/useMutatingOss';
import { useOssEdit } from './OssEditContext';
export function MenuEditOss() {
const { isAnonymous } = useAuthSuspense();
const editMenu = useDropdown();
const { schema, isMutable } = useOssEdit();
const isProcessing = useMutatingOss();
const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents);
function handleRelocate() {
editMenu.hide();
showRelocateConstituents({
oss: schema,
initialTarget: undefined,
positions: []
});
}
if (isAnonymous) {
return null;
}
return (
<div ref={editMenu.ref}>
<Button
dense
noBorder
noOutline
tabIndex={-1}
title='Редактирование'
hideTitle={editMenu.isOpen}
className='h-full px-2'
icon={<IconEdit2 size='1.25rem' className={isMutable ? 'icon-green' : 'icon-red'} />}
onClick={editMenu.toggle}
/>
<Dropdown isOpen={editMenu.isOpen}>
<DropdownButton
text='Конституенты'
titleHtml='Перенос конституент</br>между схемами'
icon={<IconChild size='1rem' className='icon-green' />}
disabled={isProcessing}
onClick={handleRelocate}
/>
</Dropdown>
</div>
);
}

View File

@ -0,0 +1,99 @@
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth';
import { useRoleStore, UserRole } from '@/features/users';
import { Divider } from '@/components/Container';
import { Button } from '@/components/Control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import { IconDestroy, IconLibrary, IconMenu, IconNewItem, IconQR, IconShare } from '@/components/Icons';
import { useDialogsStore } from '@/stores/dialogs';
import { generatePageQR, sharePage } from '@/utils/utils';
import { useMutatingOss } from '../../backend/useMutatingOss';
import { useOssEdit } from './OssEditContext';
export function MenuMain() {
const router = useConceptNavigation();
const { isMutable, deleteSchema } = useOssEdit();
const isProcessing = useMutatingOss();
const { isAnonymous } = useAuthSuspense();
const role = useRoleStore(state => state.role);
const showQR = useDialogsStore(state => state.showQR);
const schemaMenu = useDropdown();
function handleDelete() {
schemaMenu.hide();
deleteSchema();
}
function handleShare() {
schemaMenu.hide();
sharePage();
}
function handleCreateNew() {
router.push({ path: urls.create_schema });
}
function handleShowQR() {
schemaMenu.hide();
showQR({ target: generatePageQR() });
}
return (
<div ref={schemaMenu.ref}>
<Button
dense
noBorder
noOutline
tabIndex={-1}
title='Меню'
hideTitle={schemaMenu.isOpen}
icon={<IconMenu size='1.25rem' className='clr-text-controls' />}
className='h-full pl-2'
onClick={schemaMenu.toggle}
/>
<Dropdown isOpen={schemaMenu.isOpen}>
<DropdownButton
text='Поделиться'
icon={<IconShare size='1rem' className='icon-primary' />}
onClick={handleShare}
/>
<DropdownButton
text='QR-код'
title='Показать QR-код схемы'
icon={<IconQR size='1rem' className='icon-primary' />}
onClick={handleShowQR}
/>
{isMutable ? (
<DropdownButton
text='Удалить схему'
icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={isProcessing || role < UserRole.OWNER}
onClick={handleDelete}
/>
) : null}
<Divider margins='mx-3 my-1' />
{!isAnonymous ? (
<DropdownButton
text='Создать новую схему'
icon={<IconNewItem size='1rem' className='icon-primary' />}
onClick={handleCreateNew}
/>
) : null}
<DropdownButton
text='Библиотека'
icon={<IconLibrary size='1rem' className='icon-primary' />}
onClick={() => router.push({ path: urls.library })}
/>
</Dropdown>
</div>
);
}

View File

@ -1,213 +1,22 @@
'use client'; 'use client';
import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { useRoleStore, UserRole } from '@/features/users'; import { MenuRole } from '@/features/library/components';
import { Divider } from '@/components/Container';
import { Button } from '@/components/Control';
import { Dropdown, DropdownButton, useDropdown } from '@/components/Dropdown';
import {
IconAdmin,
IconAlert,
IconChild,
IconDestroy,
IconEdit2,
IconEditor,
IconLibrary,
IconMenu,
IconNewItem,
IconOwner,
IconReader,
IconShare
} from '@/components/Icons';
import { describeAccessMode as describeUserRole, labelAccessMode as labelUserRole } from '@/utils/labels';
import { sharePage } from '@/utils/utils';
import { useMutatingOss } from '../../backend/useMutatingOss';
import { MenuEditOss } from './MenuEditOss';
import { MenuMain } from './MenuMain';
import { useOssEdit } from './OssEditContext'; import { useOssEdit } from './OssEditContext';
export function MenuOssTabs() { export function MenuOssTabs() {
const { deleteSchema, promptRelocateConstituents, isMutable, isOwned, schema } = useOssEdit(); const { isOwned, schema } = useOssEdit();
const router = useConceptNavigation(); const { user } = useAuthSuspense();
const { user, isAnonymous } = useAuthSuspense();
const isProcessing = useMutatingOss();
const role = useRoleStore(state => state.role);
const setRole = useRoleStore(state => state.setRole);
const schemaMenu = useDropdown();
const editMenu = useDropdown();
const accessMenu = useDropdown();
function handleDelete() {
schemaMenu.hide();
deleteSchema();
}
function handleShare() {
schemaMenu.hide();
sharePage();
}
function handleChangeRole(newMode: UserRole) {
accessMenu.hide();
setRole(newMode);
}
function handleCreateNew() {
router.push(urls.create_schema);
}
function handleLogin() {
router.push(urls.login);
}
function handleRelocate() {
editMenu.hide();
promptRelocateConstituents(undefined, []);
}
return ( return (
<div className='flex border-r-2'> <div className='flex border-r-2'>
<div ref={schemaMenu.ref}> <MenuMain />
<Button
dense
noBorder
noOutline
tabIndex={-1}
title='Меню'
hideTitle={schemaMenu.isOpen}
icon={<IconMenu size='1.25rem' className='clr-text-controls' />}
className='h-full pl-2'
onClick={schemaMenu.toggle}
/>
<Dropdown isOpen={schemaMenu.isOpen}>
<DropdownButton
text='Поделиться'
icon={<IconShare size='1rem' className='icon-primary' />}
onClick={handleShare}
/>
{isMutable ? (
<DropdownButton
text='Удалить схему'
icon={<IconDestroy size='1rem' className='icon-red' />}
disabled={isProcessing || role < UserRole.OWNER}
onClick={handleDelete}
/>
) : null}
<Divider margins='mx-3 my-1' /> <MenuEditOss />
{!isAnonymous ? ( <MenuRole isOwned={isOwned} isEditor={!!user.id && schema.editors.includes(user.id)} />
<DropdownButton
text='Создать новую схему'
icon={<IconNewItem size='1rem' className='icon-primary' />}
onClick={handleCreateNew}
/>
) : null}
<DropdownButton
text='Библиотека'
icon={<IconLibrary size='1rem' className='icon-primary' />}
onClick={() => router.push(urls.library)}
/>
</Dropdown>
</div>
{!isAnonymous ? (
<div ref={editMenu.ref}>
<Button
dense
noBorder
noOutline
tabIndex={-1}
title='Редактирование'
hideTitle={editMenu.isOpen}
className='h-full px-2'
icon={<IconEdit2 size='1.25rem' className={isMutable ? 'icon-green' : 'icon-red'} />}
onClick={editMenu.toggle}
/>
<Dropdown isOpen={editMenu.isOpen}>
<DropdownButton
text='Конституенты'
titleHtml='Перенос конституент</br>между схемами'
icon={<IconChild size='1rem' className='icon-green' />}
disabled={isProcessing}
onClick={handleRelocate}
/>
</Dropdown>
</div>
) : null}
{!isAnonymous ? (
<div ref={accessMenu.ref}>
<Button
dense
noBorder
noOutline
tabIndex={-1}
title={`Режим ${labelUserRole(role)}`}
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
icon={
role === UserRole.ADMIN ? (
<IconAdmin size='1.25rem' className='icon-primary' />
) : role === UserRole.OWNER ? (
<IconOwner size='1.25rem' className='icon-primary' />
) : role === UserRole.EDITOR ? (
<IconEditor size='1.25rem' className='icon-primary' />
) : (
<IconReader size='1.25rem' className='icon-primary' />
)
}
onClick={accessMenu.toggle}
/>
<Dropdown isOpen={accessMenu.isOpen}>
<DropdownButton
text={labelUserRole(UserRole.READER)}
title={describeUserRole(UserRole.READER)}
icon={<IconReader size='1rem' className='icon-primary' />}
onClick={() => handleChangeRole(UserRole.READER)}
/>
<DropdownButton
text={labelUserRole(UserRole.EDITOR)}
title={describeUserRole(UserRole.EDITOR)}
icon={<IconEditor size='1rem' className='icon-primary' />}
disabled={!isOwned && (!user.id || !schema.editors.includes(user.id))}
onClick={() => handleChangeRole(UserRole.EDITOR)}
/>
<DropdownButton
text={labelUserRole(UserRole.OWNER)}
title={describeUserRole(UserRole.OWNER)}
icon={<IconOwner size='1rem' className='icon-primary' />}
disabled={!isOwned}
onClick={() => handleChangeRole(UserRole.OWNER)}
/>
<DropdownButton
text={labelUserRole(UserRole.ADMIN)}
title={describeUserRole(UserRole.ADMIN)}
icon={<IconAdmin size='1rem' className='icon-primary' />}
disabled={!user.is_staff}
onClick={() => handleChangeRole(UserRole.ADMIN)}
/>
</Dropdown>
</div>
) : null}
{isAnonymous ? (
<Button
dense
noBorder
noOutline
tabIndex={-1}
titleHtml='<b>Анонимный режим</b><br />Войти в Портал'
hideTitle={accessMenu.isOpen}
className='h-full pr-2'
icon={<IconAlert size='1.25rem' className='icon-red' />}
onClick={handleLogin}
/>
) : null}
</div> </div>
); );
} }

View File

@ -4,17 +4,17 @@ import { createContext, useContext, useEffect, useState } from 'react';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useAuthSuspense } from '@/features/auth'; import { useAuthSuspense } from '@/features/auth';
import { useDeleteItem, useLibrarySearchStore } from '@/features/library'; import { useLibrarySearchStore } from '@/features/library';
import { useDeleteItem } from '@/features/library/backend/useDeleteItem';
import { RSTabID } from '@/features/rsform/pages/RSFormPage/RSEditContext'; import { RSTabID } from '@/features/rsform/pages/RSFormPage/RSEditContext';
import { useRoleStore, UserRole } from '@/features/users'; import { useRoleStore, UserRole } from '@/features/users';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { promptText } from '@/utils/labels'; import { promptText } from '@/utils/labels';
import { type IOperationPosition, OperationType } from '../../backend/types'; import { type IOperationPosition, OperationType } from '../../backend/types';
import { useOssSuspense } from '../../backend/useOSS'; import { useOssSuspense } from '../../backend/useOSS';
import { type IOperationSchema } from '../../models/oss'; import { type IOperation, type IOperationSchema } from '../../models/oss';
export enum OssTabID { export enum OssTabID {
CARD = 0, CARD = 0,
@ -39,15 +39,9 @@ export interface IOssEditContext {
navigateTab: (tab: OssTabID) => void; navigateTab: (tab: OssTabID) => void;
navigateOperationSchema: (target: number) => void; navigateOperationSchema: (target: number) => void;
canDeleteOperation: (target: IOperation) => boolean;
deleteSchema: () => void; deleteSchema: () => void;
setSelected: React.Dispatch<React.SetStateAction<number[]>>; setSelected: React.Dispatch<React.SetStateAction<number[]>>;
canDelete: (target: number) => boolean;
promptCreateOperation: (props: ICreateOperationPrompt) => void;
promptDeleteOperation: (target: number, positions: IOperationPosition[]) => void;
promptEditInput: (target: number, positions: IOperationPosition[]) => void;
promptEditOperation: (target: number, positions: IOperationPosition[]) => void;
promptRelocateConstituents: (target: number | undefined, positions: IOperationPosition[]) => void;
} }
const OssEditContext = createContext<IOssEditContext | null>(null); const OssEditContext = createContext<IOssEditContext | null>(null);
@ -80,12 +74,6 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<number[]>([]);
const showEditInput = useDialogsStore(state => state.showChangeInputSchema);
const showEditOperation = useDialogsStore(state => state.showEditOperation);
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
const showRelocateConstituents = useDialogsStore(state => state.showRelocateConstituents);
const showCreateOperation = useDialogsStore(state => state.showCreateOperation);
const { deleteItem } = useDeleteItem(); const { deleteItem } = useDeleteItem();
useEffect( useEffect(
@ -104,7 +92,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
id: schema.id, id: schema.id,
tab: tab tab: tab
}); });
router.push(url); router.push({ path: url });
} }
function navigateOperationSchema(target: number) { function navigateOperationSchema(target: number) {
@ -112,86 +100,29 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
if (!node?.result) { if (!node?.result) {
return; return;
} }
router.push(urls.schema_props({ id: node.result, tab: RSTabID.CST_LIST })); router.push({ path: urls.schema_props({ id: node.result, tab: RSTabID.CST_LIST }) });
} }
function deleteSchema() { function deleteSchema() {
if (!window.confirm(promptText.deleteOSS)) { if (!window.confirm(promptText.deleteOSS)) {
return; return;
} }
void deleteItem(schema.id).then(() => { void deleteItem({
target: schema.id,
beforeInvalidate: () => {
if (searchLocation === schema.location) { if (searchLocation === schema.location) {
setSearchLocation(''); setSearchLocation('');
} }
router.push(urls.library); return router.pushAsync({ path: urls.library, force: true });
}
}); });
} }
function promptCreateOperation({ defaultX, defaultY, inputs, positions, callback }: ICreateOperationPrompt) { function canDeleteOperation(target: IOperation) {
showCreateOperation({ if (target.operation_type === OperationType.INPUT) {
oss: schema,
defaultX: defaultX,
defaultY: defaultY,
positions: positions,
initialInputs: inputs,
onCreate: callback
});
}
function canDelete(target: number) {
const operation = schema.operationByID.get(target);
if (!operation) {
return false;
}
if (operation.operation_type === OperationType.INPUT) {
return true; return true;
} }
return schema.graph.expandOutputs([target]).length === 0; return schema.graph.expandOutputs([target.id]).length === 0;
}
function promptEditOperation(target: number, positions: IOperationPosition[]) {
const operation = schema.operationByID.get(target);
if (!operation) {
return;
}
showEditOperation({
oss: schema,
target: operation,
positions: positions
});
}
function promptDeleteOperation(target: number, positions: IOperationPosition[]) {
const operation = schema.operationByID.get(target);
if (!operation) {
return;
}
showDeleteOperation({
oss: schema,
positions: positions,
target: operation
});
}
function promptEditInput(target: number, positions: IOperationPosition[]) {
const operation = schema.operationByID.get(target);
if (!operation) {
return;
}
showEditInput({
oss: schema,
target: operation,
positions: positions
});
}
function promptRelocateConstituents(target: number | undefined, positions: IOperationPosition[]) {
const operation = target ? schema.operationByID.get(target) : undefined;
showRelocateConstituents({
oss: schema,
initialTarget: operation,
positions: positions
});
} }
return ( return (
@ -200,22 +131,15 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
schema, schema,
selected, selected,
navigateTab,
deleteSchema,
isOwned, isOwned,
isMutable, isMutable,
setSelected, navigateTab,
navigateOperationSchema, navigateOperationSchema,
promptCreateOperation,
canDelete, canDeleteOperation,
promptDeleteOperation, deleteSchema,
promptEditInput, setSelected
promptEditOperation,
promptRelocateConstituents
}} }}
> >
{children} {children}

View File

@ -6,6 +6,7 @@ import { useParams } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { urls, useBlockNavigation, useConceptNavigation } from '@/app'; import { urls, useBlockNavigation, useConceptNavigation } from '@/app';
import { ConstituentaTooltip } from '@/features/rsform/components';
import { isAxiosError } from '@/backend/apiTransport'; import { isAxiosError } from '@/backend/apiTransport';
import { TextURL } from '@/components/Control'; import { TextURL } from '@/components/Control';
@ -13,6 +14,8 @@ import { type ErrorData } from '@/components/InfoError';
import { useQueryStrings } from '@/hooks/useQueryStrings'; import { useQueryStrings } from '@/hooks/useQueryStrings';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { OperationTooltip } from '../../components/OperationTooltip';
import { OssEditState, OssTabID } from './OssEditContext'; import { OssEditState, OssTabID } from './OssEditContext';
import { OssTabs } from './OssTabs'; import { OssTabs } from './OssTabs';
@ -37,12 +40,14 @@ export function OssPage() {
useEffect(() => setIsModified(false), [setIsModified]); useEffect(() => setIsModified(false), [setIsModified]);
if (!urlData.id) { if (!urlData.id) {
router.replace(urls.page404); router.replace({ path: urls.page404, force: true });
return null; return null;
} }
return ( return (
<ErrorBoundary FallbackComponent={ProcessError}> <ErrorBoundary FallbackComponent={ProcessError}>
<OperationTooltip />
<ConstituentaTooltip />
<OssEditState itemID={urlData.id}> <OssEditState itemID={urlData.id}>
<OssTabs activeTab={urlData.tab} /> <OssTabs activeTab={urlData.tab} />
</OssEditState> </OssEditState>

View File

@ -0,0 +1,13 @@
import { create } from 'zustand';
import { type IOperation } from '../models/oss';
interface OperationTooltipStore {
activeOperation: IOperation | null;
setActiveOperation: (value: IOperation | null) => void;
}
export const useOperationTooltipStore = create<OperationTooltipStore>()(set => ({
activeOperation: null,
setActiveOperation: value => set({ activeOperation: value })
}));

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library'; import { useUpdateTimestamp } from '@/features/library/backend/useUpdateTimestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library'; import { useUpdateTimestamp } from '@/features/library/backend/useUpdateTimestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library'; import { useUpdateTimestamp } from '@/features/library/backend/useUpdateTimestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library'; import { useUpdateTimestamp } from '@/features/library/backend/useUpdateTimestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';

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