Compare commits

..

14 Commits

Author SHA1 Message Date
Ivan
76aee5bea7 F: Implement react-query pt2
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
2025-01-21 20:33:05 +03:00
Ivan
d899e17fcd F: Implement react-query pt1 2025-01-21 12:00:09 +03:00
Ivan
bfae07c7b6 npm update 2025-01-17 12:19:21 +03:00
Ivan
6f23ec5354 R: Refactor dialogs using zustand store 2025-01-16 16:31:03 +03:00
Ivan
539ed87ddf R: Replace AccessMode with RoleStore 2025-01-15 23:03:23 +03:00
Ivan
55fa09c6fb R: Migrating to zustand for local state management pt3 2025-01-15 22:16:06 +03:00
Ivan
f526efd0ee B: Fix nodes not being selectable through hidden div 2025-01-15 19:15:52 +03:00
Ivan
08fd26e687 R: Migrating to zustand for local state management pt2 2025-01-15 16:06:18 +03:00
Ivan
26bd0ce16b R: Migrating to zustand for local state management pt1 2025-01-14 21:57:32 +03:00
Ivan
2e03756646 R: Integrate Playwrght as end-to-end testing 2025-01-14 16:24:46 +03:00
Ivan
50d29f5fee npm update 2025-01-13 15:49:15 +03:00
Ivan
a47ea5d32d M: Fix layout for small screens 2025-01-09 20:35:51 +03:00
Ivan
68dd1aa3c4 B: Fix intl version 2025-01-09 16:15:49 +03:00
Ivan
12a4b740bc npm update 2025-01-03 11:05:33 +03:00
146 changed files with 3144 additions and 2778 deletions

View File

@ -23,7 +23,6 @@ jobs:
strategy:
matrix:
node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
@ -35,10 +34,17 @@ jobs:
cache: "npm"
- name: Build
run: |
npm install -g typescript vite jest
npm install -g typescript vite jest playwright
npx playwright install --with-deps
npm ci
npm run build --if-present
- name: Run CI
run: |
npm run lint
npm test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

2
.gitignore vendored
View File

@ -67,3 +67,5 @@ bower_components
venv/
/GitExtensions.settings
rsconcept/frontend/public/privacy.pdf
/rsconcept/frontend/playwright-report
/rsconcept/frontend/test-results

View File

@ -19,7 +19,8 @@
"ms-python.pylint",
"ms-python.autopep8",
"ms-python.vscode-pylance",
"vscode-icons-team.vscode-icons"
"vscode-icons-team.vscode-icons",
"ms-playwright.playwright"
],
"unwantedRecommendations": []
}

View File

@ -119,6 +119,7 @@
"NUMR",
"Opencorpora",
"overscroll",
"partialize",
"passwordreset",
"perfectivity",
"PNCT",

View File

@ -44,7 +44,10 @@ This readme file is used mostly to document project dependencies and conventions
- use-debounce
- qrcode.react
- html-to-image
- zustand
- @tanstack/react-table
- @tanstack/react-query
- @tanstack/react-query-devtools
- @uiw/react-codemirror
- @uiw/codemirror-themes
- @lezer/lr
@ -67,6 +70,7 @@ This readme file is used mostly to document project dependencies and conventions
- ts-jest
- @types/jest
- @lezer/generator
- @playwright/test
</pre>
</details>
<details>
@ -132,6 +136,7 @@ This readme file is used mostly to document project dependencies and conventions
- isort
- Django
- SQLite
- Playwright
</pre>
</details>

View File

@ -20,22 +20,6 @@
/>
<title>Концепт Портал</title>
<!-- <script src="https://unpkg.com/react-scan/dist/auto.global.js"></script> -->
<script>
let isDark = false;
if ('portal.theme.dark' in localStorage) {
isDark = localStorage.getItem('portal.theme.dark') === 'true';
} else if (window.matchMedia) {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
localStorage.setItem('portal.theme.dark', isDark ? 'true' : 'false');
}
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"generate": "lezer-generator src/components/RSInput/rslang/rslangFast.grammar -o src/components/RSInput/rslang/parser.ts && lezer-generator src/components/RSInput/rslang/rslangAST.grammar -o src/components/RSInput/rslang/parserAST.ts && lezer-generator src/components/RefsInput/parse/refsText.grammar -o src/components/RefsInput/parse/parser.ts",
"test": "jest",
"test": "jest && playwright test",
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
@ -14,6 +14,8 @@
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-table": "^8.20.6",
"@uiw/codemirror-themes": "^4.23.7",
"@uiw/react-codemirror": "^4.23.7",
@ -24,42 +26,44 @@
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^4.1.2",
"react-error-boundary": "^5.0.0",
"react-icons": "^5.4.0",
"react-intl": "^7.0.4",
"react-router": "^7.0.2",
"react-intl": "7.0.4",
"react-router": "^7.1.2",
"react-select": "^5.9.0",
"react-tabs": "^6.0.2",
"react-toastify": "^11.0.0",
"react-tabs": "^6.1.0",
"react-toastify": "^11.0.3",
"react-tooltip": "^5.28.0",
"react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4",
"use-debounce": "^10.0.4"
"use-debounce": "^10.0.4",
"zustand": "^5.0.3"
},
"devDependencies": {
"@lezer/generator": "^1.7.2",
"@playwright/test": "^1.49.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^19.0.0-beta-37ed2a7-20241206",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.2",
"eslint": "^9.18.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-compiler": "^19.0.0-beta-37ed2a7-20241206",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.13.0",
"globals": "^15.14.0",
"jest": "^29.7.0",
"postcss": "^8.4.49",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3"
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.7"
},
"overrides": {
"react": "^19.0.0"
@ -67,6 +71,9 @@
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"testPathIgnorePatterns": [
"<rootDir>/tests/"
],
"transform": {
"node_modules/variables/.+\\.(j|t)sx?$": "ts-jest"
},

View File

@ -0,0 +1,31 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'Desktop Firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'Desktop Safari',
use: { ...devices['Desktop Safari'] }
}
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry'
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
});

View File

@ -5,12 +5,23 @@ import ConceptToaster from '@/app/ConceptToaster';
import Footer from '@/app/Footer';
import Navigation from '@/app/Navigation';
import Loader from '@/components/ui/Loader';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { NavigationState } from '@/context/NavigationContext';
import { useAppLayoutStore, useMainHeight, useViewportHeight } from '@/stores/appLayout';
import { globals } from '@/utils/constants';
import { GlobalDialogs } from './GlobalDialogs';
import { GlobalTooltips } from './GlobalTooltips';
function ApplicationLayout() {
const { viewportHeight, mainHeight, showScroll, noNavigationAnimation } = useConceptOptions();
const mainHeight = useMainHeight();
const viewportHeight = useViewportHeight();
const showScroll = useAppLayoutStore(state => !state.noScroll);
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const noFooter = useAppLayoutStore(state => state.noFooter);
// TODO: prefetch data
return (
<NavigationState>
<div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'>
@ -22,6 +33,9 @@ function ApplicationLayout() {
pauseOnFocusLoss={false}
/>
<GlobalDialogs />
<GlobalTooltips />
<Navigation />
<div
@ -36,7 +50,7 @@ function ApplicationLayout() {
<Outlet />
</Suspense>
</main>
<Footer />
{!noNavigation && !noFooter ? <Footer /> : null}
</div>
</div>
</NavigationState>

View File

@ -1,11 +1,11 @@
import { ToastContainer, type ToastContainerProps } from 'react-toastify';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { usePreferencesStore } from '@/stores/preferences';
interface ToasterThemedProps extends Omit<ToastContainerProps, 'theme'> {}
function ToasterThemed(props: ToasterThemedProps) {
const { darkMode } = useConceptOptions();
const darkMode = usePreferencesStore(state => state.darkMode);
return <ToastContainer theme={darkMode ? 'dark' : 'light'} {...props} />;
}

View File

@ -1,15 +1,10 @@
import clsx from 'clsx';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { external_urls } from '@/utils/constants';
import TextURL from '../components/ui/TextURL';
function Footer() {
const { noNavigation, noFooter } = useConceptOptions();
if (noNavigation || noFooter) {
return null;
}
return (
<footer
className={clsx(

View File

@ -0,0 +1,83 @@
'use client';
import DlgChangeInputSchema from '@/dialogs/DlgChangeInputSchema';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import DlgCloneLibraryItem from '@/dialogs/DlgCloneLibraryItem';
import DlgCreateCst from '@/dialogs/DlgCreateCst';
import DlgCreateOperation from '@/dialogs/DlgCreateOperation';
import DlgCreateVersion from '@/dialogs/DlgCreateVersion';
import DlgCstTemplate from '@/dialogs/DlgCstTemplate';
import DlgDeleteCst from '@/dialogs/DlgDeleteCst';
import DlgDeleteOperation from '@/dialogs/DlgDeleteOperation';
import DlgEditEditors from '@/dialogs/DlgEditEditors';
import DlgEditOperation from '@/dialogs/DlgEditOperation';
import DlgEditReference from '@/dialogs/DlgEditReference';
import DlgEditVersions from '@/dialogs/DlgEditVersions';
import DlgEditWordForms from '@/dialogs/DlgEditWordForms';
import DlgGraphParams from '@/dialogs/DlgGraphParams';
import DlgInlineSynthesis from '@/dialogs/DlgInlineSynthesis';
import DlgRelocateConstituents from '@/dialogs/DlgRelocateConstituents';
import DlgRenameCst from '@/dialogs/DlgRenameCst';
import DlgShowAST from '@/dialogs/DlgShowAST';
import DlgShowQR from '@/dialogs/DlgShowQR';
import DlgShowTypeGraph from '@/dialogs/DlgShowTypeGraph';
import DlgSubstituteCst from '@/dialogs/DlgSubstituteCst';
import DlgUploadRSForm from '@/dialogs/DlgUploadRSForm';
import { DialogType } from '@/models/miscellaneous';
import { useDialogsStore } from '@/stores/dialogs';
export const GlobalDialogs = () => {
const active = useDialogsStore(state => state.active);
if (active === undefined) {
return null;
}
switch (active) {
case DialogType.CONSTITUENTA_TEMPLATE:
return <DlgCstTemplate />;
case DialogType.CREATE_CONSTITUENTA:
return <DlgCreateCst />;
case DialogType.CREATE_OPERATION:
return <DlgCreateOperation />;
case DialogType.DELETE_CONSTITUENTA:
return <DlgDeleteCst />;
case DialogType.EDIT_EDITORS:
return <DlgEditEditors />;
case DialogType.EDIT_OPERATION:
return <DlgEditOperation />;
case DialogType.EDIT_REFERENCE:
return <DlgEditReference />;
case DialogType.EDIT_VERSIONS:
return <DlgEditVersions />;
case DialogType.EDIT_WORD_FORMS:
return <DlgEditWordForms />;
case DialogType.INLINE_SYNTHESIS:
return <DlgInlineSynthesis />;
case DialogType.SHOW_AST:
return <DlgShowAST />;
case DialogType.SHOW_TYPE_GRAPH:
return <DlgShowTypeGraph />;
case DialogType.CHANGE_INPUT_SCHEMA:
return <DlgChangeInputSchema />;
case DialogType.CHANGE_LOCATION:
return <DlgChangeLocation />;
case DialogType.CLONE_LIBRARY_ITEM:
return <DlgCloneLibraryItem />;
case DialogType.CREATE_VERSION:
return <DlgCreateVersion />;
case DialogType.DELETE_OPERATION:
return <DlgDeleteOperation />;
case DialogType.GRAPH_PARAMETERS:
return <DlgGraphParams />;
case DialogType.RELOCATE_CONSTITUENTS:
return <DlgRelocateConstituents />;
case DialogType.RENAME_CONSTITUENTA:
return <DlgRenameCst />;
case DialogType.SHOW_QR_CODE:
return <DlgShowQR />;
case DialogType.SUBSTITUTE_CONSTITUENTS:
return <DlgSubstituteCst />;
case DialogType.UPLOAD_RSFORM:
return <DlgUploadRSForm />;
}
};

View File

@ -1,13 +1,13 @@
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ErrorBoundary } from 'react-error-boundary';
import { IntlProvider } from 'react-intl';
import { AuthState } from '@/context/AuthContext';
import { OptionsState } from '@/context/ConceptOptionsContext';
import { queryClient } from '@/backend/queryClient';
import { GlobalOssState } from '@/context/GlobalOssContext';
import { LibraryState } from '@/context/LibraryContext';
import { UsersState } from '@/context/UsersContext';
import ErrorFallback from './ErrorFallback';
@ -31,19 +31,16 @@ function GlobalProviders({ children }: React.PropsWithChildren) {
onError={logError}
>
<IntlProvider locale='ru' defaultLocale='ru'>
<OptionsState>
<UsersState>
<AuthState>
<QueryClientProvider client={queryClient}>
<LibraryState>
<GlobalOssState>
<ReactQueryDevtools initialIsOpen={false} />
{children}
</GlobalOssState>
</LibraryState>
</AuthState>
</UsersState>
</OptionsState>
</QueryClientProvider>
</IntlProvider>
</ErrorBoundary>);
}

View File

@ -0,0 +1,32 @@
'use client';
import InfoConstituenta from '@/components/info/InfoConstituenta';
import Loader from '@/components/ui/Loader';
import Tooltip from '@/components/ui/Tooltip';
import { useTooltipsStore } from '@/stores/tooltips';
import { globals } from '@/utils/constants';
export const GlobalTooltips = () => {
const hoverCst = useTooltipsStore(state => state.activeCst);
return (
<>
<Tooltip
float
id={globals.tooltip}
layer='z-topmost'
place='right-start'
className='mt-8 max-w-[20rem] break-words'
/>
<Tooltip
float
id={globals.value_tooltip}
layer='z-topmost'
className='max-w-[calc(min(40rem,100dvw-2rem))] text-justify'
/>
<Tooltip clickable id={globals.constituenta_tooltip} layer='z-modalTooltip' className='max-w-[30rem]'>
{hoverCst ? <InfoConstituenta data={hoverCst} onClick={event => event.stopPropagation()} /> : <Loader />}
</Tooltip>
</>
);
};

View File

@ -1,8 +1,8 @@
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import useWindowSize from '@/hooks/useWindowSize';
import { usePreferencesStore } from '@/stores/preferences';
function Logo() {
const { darkMode } = useConceptOptions();
const darkMode = usePreferencesStore(state => state.darkMode);
const size = useWindowSize();
return (

View File

@ -2,9 +2,9 @@ import clsx from 'clsx';
import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/Icons';
import { CProps } from '@/components/props';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import useWindowSize from '@/hooks/useWindowSize';
import { useAppLayoutStore } from '@/stores/appLayout';
import { PARAMETER } from '@/utils/constants';
import { urls } from '../urls';
@ -16,7 +16,7 @@ import UserMenu from './UserMenu';
function Navigation() {
const router = useConceptNavigation();
const size = useWindowSize();
const { noNavigationAnimation } = useConceptOptions();
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const navigateHome = (event: CProps.EventMouse) => router.push(urls.home, event.ctrlKey || event.metaKey);
const navigateLibrary = (event: CProps.EventMouse) => router.push(urls.library, event.ctrlKey || event.metaKey);
@ -29,7 +29,8 @@ function Navigation() {
className={clsx(
'z-navigation', // prettier: split lines
'sticky top-0 left-0 right-0',
'select-none'
'select-none',
'bg-prim-100'
)}
>
<ToggleNavigation />
@ -57,6 +58,7 @@ function Navigation() {
<NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} />
<NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} />
<NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} />
<UserMenu />
</div>
</div>

View File

@ -1,11 +1,16 @@
import clsx from 'clsx';
import { IconDarkTheme, IconLightTheme, IconPin, IconUnpin } from '@/components/Icons';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useAppLayoutStore } from '@/stores/appLayout';
import { usePreferencesStore } from '@/stores/preferences';
import { globals, PARAMETER } from '@/utils/constants';
function ToggleNavigation() {
const { noNavigationAnimation, noNavigation, toggleNoNavigation, toggleDarkMode, darkMode } = useConceptOptions();
const darkMode = usePreferencesStore(state => state.darkMode);
const toggleDarkMode = usePreferencesStore(state => state.toggleDarkMode);
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const toggleNoNavigation = useAppLayoutStore(state => state.toggleNoNavigation);
const iconSize = !noNavigationAnimation ? '0.75rem' : '1rem';
return (
<div

View File

@ -0,0 +1,35 @@
import { useAuthSuspense } from '@/backend/auth/useAuth';
import { IconLogin, IconUser2 } from '@/components/Icons';
import { usePreferencesStore } from '@/stores/preferences';
import NavigationButton from './NavigationButton';
interface UserButtonProps {
onLogin: () => void;
onClickUser: () => void;
}
function UserButton({ onLogin, onClickUser }: UserButtonProps) {
const { user } = useAuthSuspense();
const adminMode = usePreferencesStore(state => state.adminMode);
if (!user) {
return (
<NavigationButton
className='cc-fade-in'
title='Перейти на страницу логина'
icon={<IconLogin size='1.5rem' className='icon-primary' />}
onClick={onLogin}
/>
);
} else {
return (
<NavigationButton
className='cc-fade-in'
icon={<IconUser2 size='1.5rem' className={adminMode && user.is_staff ? 'icon-primary' : ''} />}
onClick={onClickUser}
/>
);
}
}
export default UserButton;

View File

@ -1,3 +1,5 @@
import { useAuth } from '@/backend/auth/useAuth';
import { useLogout } from '@/backend/auth/useLogout';
import {
IconAdmin,
IconAdminOff,
@ -15,9 +17,8 @@ import {
import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton';
import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { usePreferencesStore } from '@/stores/preferences';
import { urls } from '../urls';
@ -27,9 +28,16 @@ interface UserDropdownProps {
}
function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
const { darkMode, adminMode, toggleAdminMode, toggleDarkMode, showHelp, toggleShowHelp } = useConceptOptions();
const router = useConceptNavigation();
const { user, logout } = useAuth();
const { user } = useAuth();
const { logout } = useLogout();
const darkMode = usePreferencesStore(state => state.darkMode);
const toggleDarkMode = usePreferencesStore(state => state.toggleDarkMode);
const showHelp = usePreferencesStore(state => state.showHelp);
const toggleShowHelp = usePreferencesStore(state => state.toggleShowHelp);
const adminMode = usePreferencesStore(state => state.adminMode);
const toggleAdminMode = usePreferencesStore(state => state.toggleAdminMode);
function navigateProfile(event: CProps.EventMouse) {
hideDropdown();

View File

@ -1,41 +1,22 @@
import { IconLogin, IconUser2 } from '@/components/Icons';
import { Suspense } from 'react';
import Loader from '@/components/ui/Loader';
import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import useDropdown from '@/hooks/useDropdown';
import { urls } from '../urls';
import NavigationButton from './NavigationButton';
import UserButton from './UserButton';
import UserDropdown from './UserDropdown';
function UserMenu() {
const router = useConceptNavigation();
const { user, loading } = useAuth();
const { adminMode } = useConceptOptions();
const menu = useDropdown();
const navigateLogin = () => router.push(urls.login);
return (
<div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'>
{loading ? <Loader circular scale={1.5} /> : null}
{!user && !loading ? (
<NavigationButton
className='cc-fade-in'
title='Перейти на страницу логина'
icon={<IconLogin size='1.5rem' className='icon-primary' />}
onClick={navigateLogin}
/>
) : null}
{user && !loading ? (
<NavigationButton
className='cc-fade-in'
icon={<IconUser2 size='1.5rem' className={adminMode && user.is_staff ? 'icon-primary' : ''} />}
onClick={menu.toggle}
/>
) : null}
<UserDropdown isOpen={!!user && menu.isOpen} hideDropdown={() => menu.hide()} />
<Suspense fallback={<Loader circular scale={1.5} />}>
<UserButton onLogin={() => router.push(urls.login)} onClickUser={menu.toggle} />
</Suspense>
<UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
</div>
);
}

View File

@ -42,17 +42,17 @@ export interface IAxiosRequest<RequestData, ResponseData> {
// ================ Transport API calls ================
export function AxiosGet<ResponseData>({ endpoint, request, options }: IAxiosRequest<undefined, ResponseData>) {
if (request.setLoading) request.setLoading(true);
request.setLoading?.(true);
axiosInstance
.get<ResponseData>(endpoint, options)
.then(response => {
if (request.setLoading) request.setLoading(false);
if (request.onSuccess) request.onSuccess(response.data);
request.setLoading?.(false);
request.onSuccess?.(response.data);
})
.catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false);
request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error);
request.onError?.(error);
});
}
@ -61,17 +61,17 @@ export function AxiosPost<RequestData, ResponseData>({
request,
options
}: IAxiosRequest<RequestData, ResponseData>) {
if (request.setLoading) request.setLoading(true);
request.setLoading?.(true);
axiosInstance
.post<ResponseData>(endpoint, request.data, options)
.then(response => {
if (request.setLoading) request.setLoading(false);
if (request.onSuccess) request.onSuccess(response.data);
request.setLoading?.(false);
request.onSuccess?.(response.data);
})
.catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false);
request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error);
request.onError?.(error);
});
}
@ -80,17 +80,17 @@ export function AxiosDelete<RequestData, ResponseData>({
request,
options
}: IAxiosRequest<RequestData, ResponseData>) {
if (request.setLoading) request.setLoading(true);
request.setLoading?.(true);
axiosInstance
.delete<ResponseData>(endpoint, options)
.then(response => {
if (request.setLoading) request.setLoading(false);
if (request.onSuccess) request.onSuccess(response.data);
request.setLoading?.(false);
request.onSuccess?.(response.data);
})
.catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false);
request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error);
request.onError?.(error);
});
}
@ -99,17 +99,17 @@ export function AxiosPatch<RequestData, ResponseData>({
request,
options
}: IAxiosRequest<RequestData, ResponseData>) {
if (request.setLoading) request.setLoading(true);
request.setLoading?.(true);
axiosInstance
.patch<ResponseData>(endpoint, request.data, options)
.then(response => {
if (request.setLoading) request.setLoading(false);
if (request.onSuccess) request.onSuccess(response.data);
request.setLoading?.(false);
request.onSuccess?.(response.data);
return response.data;
})
.catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false);
request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error);
request.onError?.(error);
});
}

View File

@ -0,0 +1,67 @@
import { queryOptions } from '@tanstack/react-query';
import { ICurrentUser, IUser } from '@/models/user';
import { axiosInstance } from '../apiConfiguration';
/**
* Represents login data, used to authenticate users.
*/
export interface IUserLoginDTO {
username: string;
password: string;
}
/**
* Represents data needed to update password for current user.
*/
export interface IChangePasswordDTO {
old_password: string;
new_password: string;
}
/**
* Represents password reset request data.
*/
export interface IRequestPasswordDTO extends Pick<IUser, 'email'> {}
/**
* Represents password reset data.
*/
export interface IResetPasswordDTO {
password: string;
token: string;
}
/**
* Represents password token data.
*/
export interface IPasswordTokenDTO extends Pick<IResetPasswordDTO, 'token'> {}
/**
* Authentication API.
*/
export const authApi = {
baseKey: 'auth',
getAuthQueryOptions: () => {
return queryOptions({
queryKey: [authApi.baseKey, 'user'],
queryFn: meta =>
axiosInstance
.get<ICurrentUser>('/users/api/auth', {
signal: meta.signal
})
.then(response => (response.data.id === null ? null : response.data)),
placeholderData: null,
staleTime: 24 * 60 * 60 * 1000
});
},
logout: () => axiosInstance.post('/users/api/logout'),
login: (data: IUserLoginDTO) => axiosInstance.post('/users/api/login', data),
changePassword: (data: IChangePasswordDTO) => axiosInstance.post('/users/api/change-password', data),
requestPasswordReset: (data: IRequestPasswordDTO) => axiosInstance.post('/users/api/password-reset', data),
validatePasswordToken: (data: IPasswordTokenDTO) => axiosInstance.post('/users/api/password-reset/validate', data),
resetPassword: (data: IResetPasswordDTO) => axiosInstance.post('/users/api/password-reset/confirm', data)
};

View File

@ -0,0 +1,21 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { authApi } from './api';
export function useAuth() {
const {
data: user,
isLoading,
error
} = useQuery({
...authApi.getAuthQueryOptions()
});
return { user, isLoading, error };
}
export function useAuthSuspense() {
const { data: user } = useSuspenseQuery({
...authApi.getAuthQueryOptions()
});
return { user };
}

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi, IChangePasswordDTO } from './api';
export const useChangePassword = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['change-password'],
mutationFn: authApi.changePassword,
onSettled: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return {
changePassword: (data: IChangePasswordDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from './api';
export const useLogin = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['login'],
mutationFn: authApi.login,
onSettled: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return {
login: (username: string, password: string, onSuccess?: () => void) =>
mutation.mutate({ username, password }, { onSuccess }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from './api';
export const useLogout = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['logout'],
mutationFn: authApi.logout,
onSettled: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return { logout: (onSuccess?: () => void) => mutation.mutate(undefined, { onSuccess }) };
};

View File

@ -0,0 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { authApi, IRequestPasswordDTO } from './api';
export const useRequestPasswordReset = () => {
const mutation = useMutation({
mutationKey: ['request-password-reset'],
mutationFn: authApi.requestPasswordReset
});
return {
requestPasswordReset: (data: IRequestPasswordDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi, IPasswordTokenDTO, IResetPasswordDTO } from './api';
export const useResetPassword = () => {
const queryClient = useQueryClient();
const validateMutation = useMutation({
mutationKey: ['reset-password'],
mutationFn: authApi.validatePasswordToken,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
const resetMutation = useMutation({
mutationKey: ['reset-password'],
mutationFn: authApi.resetPassword,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return {
validateToken: (data: IPasswordTokenDTO, onSuccess?: () => void) => validateMutation.mutate(data, { onSuccess }),
resetPassword: (data: IResetPasswordDTO, onSuccess?: () => void) => resetMutation.mutate(data, { onSuccess }),
isPending: resetMutation.isPending,
error: resetMutation.error,
reset: resetMutation.reset
};
};

View File

@ -0,0 +1,21 @@
import { QueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
declare module '@tanstack/react-query' {
interface Register {
defaultError: AxiosError;
}
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000,
retry: 3,
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchOnReconnect: true
}
}
});

View File

@ -1,99 +0,0 @@
/**
* Endpoints: users.
*/
import {
ICurrentUser,
IPasswordTokenData,
IRequestPasswordData,
IResetPasswordData,
IUserInfo,
IUserLoginData,
IUserProfile,
IUserSignupData,
IUserUpdateData,
IUserUpdatePassword
} from '@/models/user';
import { AxiosGet, AxiosPatch, AxiosPost, FrontAction, FrontExchange, FrontPull, FrontPush } from './apiTransport';
export function getAuth(request: FrontPull<ICurrentUser>) {
AxiosGet({
endpoint: `/users/api/auth`,
request: request
});
}
export function postLogin(request: FrontPush<IUserLoginData>) {
AxiosPost({
endpoint: '/users/api/login',
request: request
});
}
export function postLogout(request: FrontAction) {
AxiosPost({
endpoint: '/users/api/logout',
request: request
});
}
export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>) {
AxiosPost({
endpoint: '/users/api/signup',
request: request
});
}
export function getProfile(request: FrontPull<IUserProfile>) {
AxiosGet({
endpoint: '/users/api/profile',
request: request
});
}
export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfile>) {
AxiosPatch({
endpoint: '/users/api/profile',
request: request
});
}
export function patchPassword(request: FrontPush<IUserUpdatePassword>) {
AxiosPatch({
endpoint: '/users/api/change-password',
request: request
});
}
export function postRequestPasswordReset(request: FrontPush<IRequestPasswordData>) {
// title: 'Request password reset',
AxiosPost({
endpoint: '/users/api/password-reset',
request: request
});
}
export function postValidatePasswordToken(request: FrontPush<IPasswordTokenData>) {
// title: 'Validate password token',
AxiosPost({
endpoint: '/users/api/password-reset/validate',
request: request
});
}
export function postResetPassword(request: FrontPush<IResetPasswordData>) {
// title: 'Reset password',
AxiosPost({
endpoint: '/users/api/password-reset/confirm',
request: request
});
}
export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
// title: 'Active users list',
AxiosGet({
endpoint: '/users/api/active-users',
request: request
});
}

View File

@ -0,0 +1,40 @@
import { queryOptions } from '@tanstack/react-query';
import { IUser, IUserInfo, IUserProfile, IUserSignupData } from '@/models/user';
import { axiosInstance } from '../apiConfiguration';
/**
* Represents user data, intended to update user profile in persistent storage.
*/
export interface IUpdateProfileDTO extends Omit<IUser, 'is_staff' | 'id'> {}
export const usersApi = {
baseKey: 'users',
getUsersQueryOptions: () =>
queryOptions({
queryKey: [usersApi.baseKey, 'list'],
queryFn: meta =>
axiosInstance
.get<IUserInfo[]>('/users/api/active-users', {
signal: meta.signal
})
.then(response => response.data),
placeholderData: []
}),
getProfileQueryOptions: () =>
queryOptions({
queryKey: [usersApi.baseKey, 'profile'],
queryFn: meta =>
axiosInstance
.get<IUserProfile>('/users/api/profile', {
signal: meta.signal
})
.then(response => response.data)
}),
signup: (data: IUserSignupData) => axiosInstance.post('/users/api/signup', data),
updateProfile: (data: IUpdateProfileDTO) => axiosInstance.patch('/users/api/profile', data)
};
//DataCallback<IUserProfile>

View File

@ -0,0 +1,25 @@
import { useUsers } from './useUsers';
export function useLabelUser() {
const { users } = useUsers();
function getUserLabel(userID: number | null): string {
const user = users.find(({ id }) => id === userID);
if (!user || userID === null) {
return userID ? `Аноним ${userID.toString()}` : 'Отсутствует';
}
const hasFirstName = user.first_name !== '';
const hasLastName = user.last_name !== '';
if (hasFirstName || hasLastName) {
if (!hasLastName) {
return user.first_name;
}
if (!hasFirstName) {
return user.last_name;
}
return user.last_name + ' ' + user.first_name;
}
return `Аноним ${userID.toString()}`;
}
return getUserLabel;
}

View File

@ -0,0 +1,21 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { usersApi } from './api';
export function useProfile() {
const {
data: profile,
isLoading,
error
} = useQuery({
...usersApi.getProfileQueryOptions()
});
return { profile, isLoading, error };
}
export function useProfileSuspense() {
const { data: profile } = useSuspenseQuery({
...usersApi.getProfileQueryOptions()
});
return { profile };
}

View File

@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '@/backend/users/api';
import { IUserProfile, IUserSignupData } from '@/models/user';
import { DataCallback } from '../apiTransport';
export const useSignup = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['signup'],
mutationFn: usersApi.signup,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [usersApi.baseKey] })
});
return {
signup: (data: IUserSignupData, onSuccess?: DataCallback<IUserProfile>) =>
mutation.mutate(data, { onSuccess: response => onSuccess?.(response.data as IUserProfile) }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { IUserProfile } from '@/models/user';
import { IUpdateProfileDTO, usersApi } from './api';
// TODO: reload users / optimistic update
export const useUpdateProfile = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['update-profile'],
mutationFn: usersApi.updateProfile,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [usersApi.baseKey] })
});
return {
updateProfile: (data: IUpdateProfileDTO, onSuccess?: (newUser: IUserProfile) => void) =>
mutation.mutate(data, { onSuccess: response => onSuccess?.(response.data as IUserProfile) }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,18 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { usersApi } from './api';
export function useUsersSuspense() {
const { data: users } = useSuspenseQuery({
...usersApi.getUsersQueryOptions(),
initialData: []
});
return { users };
}
export function useUsers() {
const { data: users } = useQuery({
...usersApi.getUsersQueryOptions()
});
return { users: users ?? [] };
}

View File

@ -9,10 +9,10 @@ import { EditorView } from 'codemirror';
import { forwardRef, useRef } from 'react';
import Label from '@/components/ui/Label';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { ConstituentaID, IRSForm } from '@/models/rsform';
import { generateAlias, getCstTypePrefix, guessCstType } from '@/models/rsformAPI';
import { extractGlobals } from '@/models/rslangAPI';
import { usePreferencesStore } from '@/stores/preferences';
import { APP_COLORS } from '@/styling/color';
import { ccBracketMatching } from './bracketMatching';
@ -64,7 +64,7 @@ const RSInput = forwardRef<ReactCodeMirrorRef, RSInputProps>(
},
ref
) => {
const { darkMode } = useConceptOptions();
const darkMode = usePreferencesStore(state => state.darkMode);
const internalRef = useRef<ReactCodeMirrorRef>(null);
const thisRef = !ref || typeof ref === 'function' ? internalRef : ref;

View File

@ -9,13 +9,13 @@ import { EditorView } from 'codemirror';
import { forwardRef, useRef, useState } from 'react';
import Label from '@/components/ui/Label';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import DlgEditReference from '@/dialogs/DlgEditReference';
import { ReferenceType } from '@/models/language';
import { DialogType } from '@/models/miscellaneous';
import { ConstituentaID, IRSForm } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences';
import { APP_COLORS } from '@/styling/color';
import { CodeMirrorWrapper } from '@/utils/codemirror';
import { PARAMETER } from '@/utils/constants';
import { refsNavigation } from './clickNavigation';
import { NaturalLanguage, ReferenceTokens } from './parse';
@ -92,11 +92,14 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
},
ref
) => {
const { darkMode } = useConceptOptions();
const darkMode = usePreferencesStore(state => state.darkMode);
const [isFocused, setIsFocused] = useState(false);
const [showEditor, setShowEditor] = useState(false);
const showEditReference = useDialogsStore(state => state.showEditReference);
const activeDialog = useDialogsStore(state => state.active);
const isActive = activeDialog === DialogType.EDIT_REFERENCE;
const [currentType, setCurrentType] = useState<ReferenceType>(ReferenceType.ENTITY);
const [refText, setRefText] = useState('');
const [hintText, setHintText] = useState('');
@ -146,7 +149,7 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
}
function handleInput(event: React.KeyboardEvent<HTMLDivElement>) {
if (!thisRef.current?.view) {
if (!thisRef.current?.view || !schema) {
event.preventDefault();
event.stopPropagation();
return;
@ -174,7 +177,17 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
setMainRefs(mainNodes.map(node => wrap.getText(node.from, node.to)));
setBasePosition(mainNodes.filter(node => node.to <= selection.from).length);
setShowEditor(true);
showEditReference({
schema: schema,
initial: {
type: currentType,
refRaw: refText,
text: hintText,
basePosition: basePosition,
mainRefs: mainRefs
},
onSave: handleInputReference
});
}
}
@ -187,27 +200,8 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
wrap.replaceWith(referenceText);
}
function hideEditReference() {
setShowEditor(false);
setTimeout(() => thisRef.current?.view?.focus(), PARAMETER.refreshTimeout);
}
return (
<div className={clsx('flex flex-col gap-2', cursor)}>
{showEditor && schema ? (
<DlgEditReference
hideWindow={hideEditReference}
schema={schema}
initial={{
type: currentType,
refRaw: refText,
text: hintText,
basePosition: basePosition,
mainRefs: mainRefs
}}
onSave={handleInputReference}
/>
) : null}
<Label text={label} />
<CodeMirror
id={id}
@ -215,10 +209,10 @@ const RefsInput = forwardRef<ReactCodeMirrorRef, RefsInputInputProps>(
basicSetup={editorSetup}
theme={customTheme}
extensions={editorExtensions}
value={isFocused ? value : value !== initialValue || showEditor ? value : resolved}
value={isFocused ? value : value !== initialValue || isActive ? value : resolved}
indentWithTab={false}
onChange={handleChange}
editable={!disabled && !showEditor}
editable={!disabled && !isActive}
onKeyDown={handleInput}
onFocus={handleFocusIn}
onBlur={handleFocusOut}

View File

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { CstClass, IConstituenta } from '@/models/rsform';
import { useTooltipsStore } from '@/stores/tooltips';
import { APP_COLORS, colorFgCstStatus } from '@/styling/color';
import { globals } from '@/utils/constants';
@ -19,7 +19,7 @@ interface BadgeConstituentaProps extends CProps.Styling {
* Displays a badge with a constituenta alias and information tooltip.
*/
function BadgeConstituenta({ value, prefixID, className, style }: BadgeConstituentaProps) {
const { setHoverCst } = useConceptOptions();
const setActiveCst = useTooltipsStore(state => state.setActiveCst);
return (
<div
@ -39,7 +39,7 @@ function BadgeConstituenta({ value, prefixID, className, style }: BadgeConstitue
...style
}}
data-tooltip-id={globals.constituenta_tooltip}
onMouseEnter={() => setHoverCst(value)}
onMouseEnter={() => setActiveCst(value)}
>
{value.alias}
</div>

View File

@ -2,8 +2,8 @@ import React, { Suspense } from 'react';
import TextURL from '@/components/ui/TextURL';
import Tooltip, { PlacesType } from '@/components/ui/Tooltip';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { HelpTopic } from '@/models/miscellaneous';
import { usePreferencesStore } from '@/stores/preferences';
import { IconHelp } from '../Icons';
import { CProps } from '../props';
@ -29,7 +29,7 @@ interface BadgeHelpProps extends CProps.Styling {
* Display help icon with a manual page tooltip.
*/
function BadgeHelp({ topic, padding = 'p-1', ...restProps }: BadgeHelpProps) {
const { showHelp } = useConceptOptions();
const showHelp = usePreferencesStore(state => state.showHelp);
if (!showHelp) {
return null;

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { useUsers } from '@/context/UsersContext';
import { useLabelUser } from '@/backend/users/useLabelUser';
import { UserID } from '@/models/user';
import { CProps } from '../props';
@ -12,8 +12,7 @@ interface InfoUsersProps extends CProps.Styling {
}
function InfoUsers({ items, className, prefix, header, ...restProps }: InfoUsersProps) {
const { getUserLabel } = useUsers();
const getUserLabel = useLabelUser();
return (
<div className={clsx('flex flex-col dense', className)} {...restProps}>
{header ? <h2>{header}</h2> : null}

View File

@ -2,17 +2,18 @@
import clsx from 'clsx';
import { useUsers } from '@/context/UsersContext';
import { IUserInfo, UserID } from '@/models/user';
import { useLabelUser } from '@/backend/users/useLabelUser';
import { useUsers } from '@/backend/users/useUsers';
import { UserID } from '@/models/user';
import { matchUser } from '@/models/userAPI';
import { CProps } from '../props';
import SelectSingle from '../ui/SelectSingle';
interface SelectUserProps extends CProps.Styling {
items?: IUserInfo[];
value?: UserID;
onSelectValue: (newValue: UserID) => void;
filter?: (userID: UserID) => boolean;
placeholder?: string;
noBorder?: boolean;
@ -20,20 +21,23 @@ interface SelectUserProps extends CProps.Styling {
function SelectUser({
className,
items,
filter,
value,
onSelectValue,
placeholder = 'Выберите пользователя',
...restProps
}: SelectUserProps) {
const { getUserLabel } = useUsers();
const { users } = useUsers();
const getUserLabel = useLabelUser();
const items = filter ? users.filter(user => filter(user.id)) : users;
const options =
items?.map(user => ({
value: user.id,
label: getUserLabel(user.id)
})) ?? [];
function filter(option: { value: UserID | undefined; label: string }, inputValue: string) {
function filterLabel(option: { value: UserID | undefined; label: string }, inputValue: string) {
const user = items?.find(item => item.id === option.value);
return !user ? false : matchUser(user, inputValue);
}
@ -47,7 +51,7 @@ function SelectUser({
if (data?.value !== undefined) onSelectValue(data.value);
}}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
filterOption={filterLabel}
placeholder={placeholder}
{...restProps}
/>

View File

@ -4,6 +4,7 @@ import clsx from 'clsx';
import useEscapeKey from '@/hooks/useEscapeKey';
import { HelpTopic } from '@/models/miscellaneous';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/labels';
@ -32,9 +33,6 @@ export interface ModalProps extends CProps.Styling {
/** Indicates that the modal window should be scrollable. */
overflowVisible?: boolean;
/** Callback to be called when the modal window is closed. */
hideWindow: () => void;
/** Callback to be called before submit. */
beforeSubmit?: () => boolean;
@ -65,7 +63,6 @@ function Modal({
canSubmit,
overflowVisible,
hideWindow,
beforeSubmit,
onSubmit,
onCancel,
@ -75,10 +72,11 @@ function Modal({
hideHelpWhen,
...restProps
}: React.PropsWithChildren<ModalProps>) {
useEscapeKey(hideWindow);
const hideDialog = useDialogsStore(state => state.hideDialog);
useEscapeKey(hideDialog);
const handleCancel = () => {
hideWindow();
hideDialog();
onCancel?.();
};
@ -87,7 +85,7 @@ function Modal({
return;
}
onSubmit?.();
hideWindow();
hideDialog();
};
return (
@ -95,7 +93,7 @@ function Modal({
<div className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'backdrop-blur-[3px] opacity-50')} />
<div
className={clsx('z-navigation', 'fixed top-0 left-0', 'w-full h-full', 'bg-prim-0 opacity-25')}
onClick={hideWindow}
onClick={hideDialog}
/>
<div
className={clsx(

View File

@ -1,7 +1,7 @@
'use client';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import useWindowSize from '@/hooks/useWindowSize';
import { useFitHeight } from '@/stores/appLayout';
/** Maximum width of the viewer. */
const MAXIMUM_WIDTH = 1600;
@ -25,10 +25,9 @@ interface PDFViewerProps {
*/
function PDFViewer({ file, offsetXpx, minWidth = MINIMUM_WIDTH }: PDFViewerProps) {
const windowSize = useWindowSize();
const { calculateHeight } = useConceptOptions();
const pageWidth = Math.max(minWidth, Math.min((windowSize?.width ?? 0) - (offsetXpx ?? 0) - 10, MAXIMUM_WIDTH));
const pageHeight = calculateHeight('1rem');
const pageHeight = useFitHeight('1rem');
return <embed src={`${file}#toolbar=0`} className='p-3' style={{ width: pageWidth, height: pageHeight }} />;
}

View File

@ -29,7 +29,7 @@ function SubmitButton({ text = 'ОК', icon, disabled, loading, className, ...re
loading && 'cursor-progress',
className
)}
disabled={disabled ?? loading}
disabled={disabled || loading}
{...restProps}
>
{icon ? <span>{icon}</span> : null}

View File

@ -5,7 +5,7 @@ import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { ITooltip, Tooltip as TooltipImpl } from 'react-tooltip';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { usePreferencesStore } from '@/stores/preferences';
export type { PlacesType } from 'react-tooltip';
@ -29,7 +29,7 @@ function Tooltip({
style,
...restProps
}: TooltipProps) {
const { darkMode } = useConceptOptions();
const darkMode = usePreferencesStore(state => state.darkMode);
if (typeof window === 'undefined') {
return null;
}

View File

@ -1,11 +1,13 @@
import { urls } from '@/app/urls';
import { useAuth } from '@/context/AuthContext';
import { useAuth } from '@/backend/auth/useAuth';
import { useLogout } from '@/backend/auth/useLogout';
import { useConceptNavigation } from '@/context/NavigationContext';
import TextURL from '../ui/TextURL';
function ExpectedAnonymous() {
const { user, logout } = useAuth();
const { user } = useAuth();
const { logout } = useLogout();
const router = useConceptNavigation();
function logoutAndRedirect() {

View File

@ -1,14 +1,14 @@
'use client';
import { useAuth } from '@/context/AuthContext';
import { useAuth } from '@/backend/auth/useAuth';
import Loader from '../ui/Loader';
import TextURL from '../ui/TextURL';
function RequireAuth({ children }: React.PropsWithChildren) {
const { user, loading } = useAuth();
const { user, isLoading } = useAuth();
if (loading) {
if (isLoading) {
return <Loader key='auth-loader' />;
}
if (user) {

View File

@ -1,25 +0,0 @@
'use client';
import { createContext, useContext, useState } from 'react';
import { UserLevel } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
interface IAccessModeContext {
accessLevel: UserLevel;
setAccessLevel: React.Dispatch<React.SetStateAction<UserLevel>>;
}
const AccessContext = createContext<IAccessModeContext | null>(null);
export const useAccessMode = () => {
const context = useContext(AccessContext);
if (!context) {
throw new Error(contextOutsideScope('useAccessMode', 'AccessModeState'));
}
return context;
};
export const AccessModeState = ({ children }: React.PropsWithChildren) => {
const [accessLevel, setAccessLevel] = useState<UserLevel>(UserLevel.READER);
return <AccessContext value={{ accessLevel, setAccessLevel }}>{children}</AccessContext>;
};

View File

@ -1,207 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import {
getAuth,
patchPassword,
postLogin,
postLogout,
postRequestPasswordReset,
postResetPassword,
postSignup,
postValidatePasswordToken
} from '@/backend/users';
import { type ErrorData } from '@/components/info/InfoError';
import {
ICurrentUser,
IPasswordTokenData,
IRequestPasswordData,
IResetPasswordData,
IUserInfo,
IUserLoginData,
IUserProfile,
IUserSignupData,
IUserUpdatePassword
} from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
import { useUsers } from './UsersContext';
interface IAuthContext {
user: ICurrentUser | undefined;
login: (data: IUserLoginData, callback?: DataCallback) => void;
logout: (callback?: DataCallback) => void;
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void;
updatePassword: (data: IUserUpdatePassword, callback?: () => void) => void;
requestPasswordReset: (data: IRequestPasswordData, callback?: () => void) => void;
validateToken: (data: IPasswordTokenData, callback?: () => void) => void;
resetPassword: (data: IResetPasswordData, callback?: () => void) => void;
loading: boolean;
error: ErrorData;
setError: (error: ErrorData) => void;
}
const AuthContext = createContext<IAuthContext | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error(contextOutsideScope('useAuth', 'AuthState'));
}
return context;
};
export const AuthState = ({ children }: React.PropsWithChildren) => {
const { users } = useUsers();
const [user, setUser] = useState<ICurrentUser | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorData>(undefined);
const reload = useCallback(
(callback?: () => void) => {
getAuth({
onError: () => setUser(undefined),
setLoading: setLoading,
onSuccess: currentUser => {
if (currentUser.id) {
setUser(currentUser);
} else {
setUser(undefined);
}
callback?.();
}
});
},
[setUser]
);
function login(data: IUserLoginData, callback?: DataCallback) {
setError(undefined);
postLogin({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: newData =>
reload(() => {
callback?.(newData);
})
});
}
function logout(callback?: DataCallback) {
setError(undefined);
postLogout({
showError: true,
onSuccess: newData =>
reload(() => {
callback?.(newData);
})
});
}
function signup(data: IUserSignupData, callback?: DataCallback<IUserProfile>) {
setError(undefined);
postSignup({
data: data,
showError: true,
setLoading: setLoading,
onError: setError,
onSuccess: newData =>
reload(() => {
users.push(newData as IUserInfo);
callback?.(newData);
})
});
}
const updatePassword = useCallback(
(data: IUserUpdatePassword, callback?: () => void) => {
setError(undefined);
patchPassword({
data: data,
showError: true,
setLoading: setLoading,
onError: setError,
onSuccess: () => reload(callback)
});
},
[reload]
);
const requestPasswordReset = useCallback(
(data: IRequestPasswordData, callback?: () => void) => {
setError(undefined);
postRequestPasswordReset({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
callback?.();
})
});
},
[reload]
);
const validateToken = useCallback(
(data: IPasswordTokenData, callback?: () => void) => {
setError(undefined);
postValidatePasswordToken({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
callback?.();
})
});
},
[reload]
);
const resetPassword = useCallback(
(data: IResetPasswordData, callback?: () => void) => {
setError(undefined);
postResetPassword({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
callback?.();
})
});
},
[reload]
);
useEffect(() => {
reload();
}, [reload]);
return (
<AuthContext
value={{
user,
login,
logout,
signup,
loading,
error,
setError,
updatePassword,
requestPasswordReset,
validateToken,
resetPassword
}}
>
{children}
</AuthContext>
);
};

View File

@ -1,194 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import InfoConstituenta from '@/components/info/InfoConstituenta';
import Loader from '@/components/ui/Loader';
import Tooltip from '@/components/ui/Tooltip';
import useLocalStorage from '@/hooks/useLocalStorage';
import { IConstituenta } from '@/models/rsform';
import { globals, PARAMETER, storage } from '@/utils/constants';
import { contextOutsideScope } from '@/utils/labels';
interface IOptionsContext {
viewportHeight: string;
mainHeight: string;
darkMode: boolean;
toggleDarkMode: () => void;
adminMode: boolean;
toggleAdminMode: () => void;
noNavigationAnimation: boolean;
noNavigation: boolean;
toggleNoNavigation: () => void;
noFooter: boolean;
setNoFooter: React.Dispatch<React.SetStateAction<boolean>>;
showScroll: boolean;
setShowScroll: React.Dispatch<React.SetStateAction<boolean>>;
showHelp: boolean;
toggleShowHelp: () => void;
folderMode: boolean;
setFolderMode: React.Dispatch<React.SetStateAction<boolean>>;
location: string;
setLocation: React.Dispatch<React.SetStateAction<string>>;
setHoverCst: (newValue: IConstituenta | undefined) => void;
calculateHeight: (offset: string, minimum?: string) => string;
}
const OptionsContext = createContext<IOptionsContext | null>(null);
export const useConceptOptions = () => {
const context = useContext(OptionsContext);
if (!context) {
throw new Error(contextOutsideScope('useConceptTheme', 'ThemeState'));
}
return context;
};
export const OptionsState = ({ children }: React.PropsWithChildren) => {
const [darkMode, setDarkMode] = useLocalStorage(storage.themeDark, false);
const [adminMode, setAdminMode] = useLocalStorage(storage.optionsAdmin, false);
const [showHelp, setShowHelp] = useLocalStorage(storage.optionsHelp, true);
const [noNavigation, setNoNavigation] = useState(false);
const [folderMode, setFolderMode] = useLocalStorage<boolean>(storage.librarySearchFolderMode, true);
const [location, setLocation] = useLocalStorage<string>(storage.librarySearchLocation, '');
const [noNavigationAnimation, setNoNavigationAnimation] = useState(false);
const [noFooter, setNoFooter] = useState(false);
const [showScroll, setShowScroll] = useState(false);
const [hoverCst, setHoverCst] = useState<IConstituenta | undefined>(undefined);
function setDarkClass(isDark: boolean) {
const root = window.document.documentElement;
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
root.setAttribute('data-color-scheme', !isDark ? 'light' : 'dark');
}
useEffect(() => {
setDarkClass(darkMode);
}, [darkMode]);
const toggleNoNavigation = useCallback(() => {
if (noNavigation) {
setNoNavigationAnimation(false);
setNoNavigation(false);
} else {
setNoNavigationAnimation(true);
setTimeout(() => setNoNavigation(true), PARAMETER.moveDuration);
}
}, [noNavigation]);
const calculateHeight = useCallback(
(offset: string, minimum: string = '0px') => {
if (noNavigation) {
return `max(calc(100dvh - (${offset})), ${minimum})`;
} else if (noFooter) {
return `max(calc(100dvh - 3rem - (${offset})), ${minimum})`;
} else {
return `max(calc(100dvh - 6.75rem - (${offset})), ${minimum})`;
}
},
[noNavigation, noFooter]
);
const toggleDarkMode = useCallback(() => {
if (!document.startViewTransition) {
setDarkMode(prev => !prev);
} else {
const style = document.createElement('style');
style.innerHTML = `
* {
animation: none !important;
transition: none !important;
}
`;
document.head.appendChild(style);
document.startViewTransition(() => {
flushSync(() => {
setDarkMode(prev => !prev);
});
});
setTimeout(() => document.head.removeChild(style), PARAMETER.moveDuration);
}
}, [setDarkMode]);
const mainHeight = useMemo(() => {
if (noNavigation) {
return '100dvh';
} else if (noFooter) {
return 'calc(100dvh - 3rem)';
} else {
return 'calc(100dvh - 6.75rem)';
}
}, [noNavigation, noFooter]);
const viewportHeight = useMemo(() => {
return !noNavigation ? 'calc(100dvh - 3rem)' : '100dvh';
}, [noNavigation]);
return (
<OptionsContext
value={{
darkMode,
adminMode,
noNavigationAnimation,
noNavigation,
noFooter,
folderMode,
setFolderMode,
location,
setLocation,
showScroll,
showHelp,
toggleDarkMode: toggleDarkMode,
toggleAdminMode: () => setAdminMode(prev => !prev),
toggleNoNavigation: toggleNoNavigation,
setNoFooter,
setShowScroll,
toggleShowHelp: () => setShowHelp(prev => !prev),
viewportHeight,
mainHeight,
calculateHeight,
setHoverCst
}}
>
<>
<Tooltip
float
id={globals.tooltip}
layer='z-topmost'
place='right-start'
className='mt-8 max-w-[20rem] break-words'
/>
<Tooltip
float
id={globals.value_tooltip}
layer='z-topmost'
className='max-w-[calc(min(40rem,100dvw-2rem))] text-justify'
/>
<Tooltip clickable id={globals.constituenta_tooltip} layer='z-modalTooltip' className='max-w-[30rem]'>
{hoverCst ? <InfoConstituenta data={hoverCst} onClick={event => event.stopPropagation()} /> : <Loader />}
</Tooltip>
{children}
</>
</OptionsContext>
);
};

View File

@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import { useAuth } from '@/backend/auth/useAuth';
import {
deleteLibraryItem,
getAdminLibrary,
@ -21,11 +22,9 @@ import { matchLibraryItem, matchLibraryItemLocation } from '@/models/libraryAPI'
import { ILibraryFilter } from '@/models/miscellaneous';
import { IRSForm, IRSFormCloneData, IRSFormData } from '@/models/rsform';
import { RSFormLoader } from '@/models/RSFormLoader';
import { usePreferencesStore } from '@/stores/preferences';
import { contextOutsideScope } from '@/utils/labels';
import { useAuth } from './AuthContext';
import { useConceptOptions } from './ConceptOptionsContext';
interface ILibraryContext {
items: ILibraryItem[];
templates: ILibraryItem[];
@ -62,8 +61,8 @@ export const useLibrary = (): ILibraryContext => {
};
export const LibraryState = ({ children }: React.PropsWithChildren) => {
const { user, loading: userLoading } = useAuth();
const { adminMode } = useConceptOptions();
const { user, isLoading: userLoading } = useAuth();
const adminMode = usePreferencesStore(state => state.adminMode);
const [items, setItems] = useState<ILibraryItem[]>([]);
const [templates, setTemplates] = useState<ILibraryItem[]>([]);

View File

@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import { useAuth } from '@/backend/auth/useAuth';
import {
patchLibraryItem,
patchSetAccessPolicy,
@ -38,7 +39,6 @@ import {
import { UserID } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
import { useAuth } from './AuthContext';
import { useGlobalOss } from './GlobalOssContext';
import { useLibrary } from './LibraryContext';

View File

@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import { useAuth } from '@/backend/auth/useAuth';
import {
patchLibraryItem,
patchSetAccessPolicy,
@ -50,7 +51,6 @@ import {
import { UserID } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
import { useAuth } from './AuthContext';
import { useGlobalOss } from './GlobalOssContext';
import { useLibrary } from './LibraryContext';

View File

@ -1,83 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import { getProfile, patchProfile } from '@/backend/users';
import { ErrorData } from '@/components/info/InfoError';
import { IUserProfile, IUserUpdateData } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
import { useUsers } from './UsersContext';
interface IUserProfileContext {
user: IUserProfile | undefined;
loading: boolean;
processing: boolean;
error: ErrorData;
errorProcessing: ErrorData;
setError: (error: ErrorData) => void;
updateUser: (data: IUserUpdateData, callback?: DataCallback<IUserProfile>) => void;
}
const ProfileContext = createContext<IUserProfileContext | null>(null);
export const useUserProfile = () => {
const context = useContext(ProfileContext);
if (!context) {
throw new Error(contextOutsideScope('useUserProfile', 'UserProfileState'));
}
return context;
};
export const UserProfileState = ({ children }: React.PropsWithChildren) => {
const { users } = useUsers();
const [user, setUser] = useState<IUserProfile | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<ErrorData>(undefined);
const [errorProcessing, setErrorProcessing] = useState<ErrorData>(undefined);
const reload = useCallback(() => {
setError(undefined);
setUser(undefined);
getProfile({
showError: true,
setLoading: setLoading,
onError: setError,
onSuccess: newData => setUser(newData)
});
}, [setUser]);
const updateUser = useCallback(
(data: IUserUpdateData, callback?: DataCallback<IUserProfile>) => {
setErrorProcessing(undefined);
patchProfile({
data: data,
showError: true,
setLoading: setProcessing,
onError: setErrorProcessing,
onSuccess: newData => {
setUser(newData);
const libraryUser = users.find(item => item.id === user?.id);
if (libraryUser) {
libraryUser.first_name = newData.first_name;
libraryUser.last_name = newData.last_name;
}
callback?.(newData);
}
});
},
[setUser, users, user?.id]
);
useEffect(() => {
reload();
}, [reload]);
return (
<ProfileContext value={{ user, updateUser, error, loading, setError, processing, errorProcessing }}>
{children}
</ProfileContext>
);
};

View File

@ -1,94 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { getActiveUsers } from '@/backend/users';
import { IUserInfo } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
interface IUsersContext {
users: IUserInfo[];
reload: (callback?: () => void) => void;
getUserLabel: (userID: number | null) => string;
}
const UsersContext = createContext<IUsersContext | null>(null);
export const useUsers = (): IUsersContext => {
const context = useContext(UsersContext);
if (context === null) {
throw new Error(contextOutsideScope('useUsers', 'UsersState'));
}
return context;
};
export const UsersState = ({ children }: React.PropsWithChildren) => {
const [users, setUsers] = useState<IUserInfo[]>([]);
function getUserLabel(userID: number | null) {
const user = users.find(({ id }) => id === userID);
if (!user) {
return userID ? userID.toString() : 'Отсутствует';
}
const hasFirstName = user.first_name !== '';
const hasLastName = user.last_name !== '';
if (hasFirstName || hasLastName) {
if (!hasLastName) {
return user.first_name;
}
if (!hasFirstName) {
return user.last_name;
}
return user.last_name + ' ' + user.first_name;
}
return `Аноним ${userID}`;
}
const reload = useCallback(
(callback?: () => void) => {
getActiveUsers({
showError: true,
onError: () => setUsers([]),
onSuccess: newData => {
newData.sort((a, b) => {
if (a.last_name === '') {
if (b.last_name === '') {
return a.id - b.id;
} else {
return 1;
}
} else if (b.last_name === '') {
if (a.last_name === '') {
return a.id - b.id;
} else {
return -1;
}
} else if (a.last_name !== b.last_name) {
return a.last_name.localeCompare(b.last_name);
} else {
return a.first_name.localeCompare(b.first_name);
}
});
setUsers(newData);
callback?.();
}
});
},
[setUsers]
);
useEffect(() => {
reload();
}, [reload]);
return (
<UsersContext
value={{
users,
reload,
getUserLabel
}}
>
{children}
</UsersContext>
);
};

View File

@ -7,19 +7,21 @@ import { IconReset } from '@/components/Icons';
import PickSchema from '@/components/select/PickSchema';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import { useLibrary } from '@/context/LibraryContext';
import { ILibraryItem, LibraryItemID, LibraryItemType } from '@/models/library';
import { IOperation, IOperationSchema } from '@/models/oss';
import { IOperation, IOperationSchema, OperationID } from '@/models/oss';
import { sortItemsForOSS } from '@/models/ossAPI';
import { useDialogsStore } from '@/stores/dialogs';
interface DlgChangeInputSchemaProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgChangeInputSchemaProps {
oss: IOperationSchema;
target: IOperation;
onSubmit: (newSchema: LibraryItemID | undefined) => void;
onSubmit: (target: OperationID, newSchema: LibraryItemID | undefined) => void;
}
function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeInputSchemaProps) {
function DlgChangeInputSchema() {
const { oss, target, onSubmit } = useDialogsStore(state => state.props as DlgChangeInputSchemaProps);
const [selected, setSelected] = useState<LibraryItemID | undefined>(target.result ?? undefined);
const library = useLibrary();
const sortedItems = sortItemsForOSS(oss, library.items);
@ -38,9 +40,8 @@ function DlgChangeInputSchema({ oss, hideWindow, target, onSubmit }: DlgChangeIn
overflowVisible
header='Выбор концептуальной схемы'
submitText='Подтвердить выбор'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={() => onSubmit(selected)}
onSubmit={() => onSubmit(target.id, selected)}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column')}
>
<div className='flex justify-between gap-3 items-center'>

View File

@ -3,23 +3,25 @@
import clsx from 'clsx';
import { useState } from 'react';
import { useAuth } from '@/backend/auth/useAuth';
import SelectLocationContext from '@/components/select/SelectLocationContext';
import SelectLocationHead from '@/components/select/SelectLocationHead';
import Label from '@/components/ui/Label';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import { LocationHead } from '@/models/library';
import { combineLocation, validateLocation } from '@/models/libraryAPI';
import { useDialogsStore } from '@/stores/dialogs';
import { limits } from '@/utils/constants';
interface DlgChangeLocationProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgChangeLocationProps {
initial: string;
onChangeLocation: (newLocation: string) => void;
}
function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeLocationProps) {
function DlgChangeLocation() {
const { initial, onChangeLocation } = useDialogsStore(state => state.props as DlgChangeLocationProps);
const { user } = useAuth();
const [head, setHead] = useState<LocationHead>(initial.substring(0, 2) as LocationHead);
const [body, setBody] = useState<string>(initial.substring(3));
@ -40,7 +42,6 @@ function DlgChangeLocation({ hideWindow, initial, onChangeLocation }: DlgChangeL
header='Изменение расположения'
submitText='Переместить'
submitInvalidTooltip={`Допустимы буквы, цифры, подчерк, пробел и "!". Сегмент пути не может начинаться и заканчиваться пробелом. Общая длина (с корнем) не должна превышать ${limits.location_len}`}
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={() => onChangeLocation(location)}
className={clsx('w-[35rem]', 'pb-3 px-6 flex gap-3 h-[9rem]')}

View File

@ -5,6 +5,7 @@ import { useState } from 'react';
import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { VisibilityIcon } from '@/components/DomainIcons';
import SelectAccessPolicy from '@/components/select/SelectAccessPolicy';
import SelectLocationContext from '@/components/select/SelectLocationContext';
@ -12,27 +13,31 @@ import SelectLocationHead from '@/components/select/SelectLocationHead';
import Checkbox from '@/components/ui/Checkbox';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { AccessPolicy, ILibraryItem, LocationHead } from '@/models/library';
import { cloneTitle, combineLocation, validateLocation } from '@/models/libraryAPI';
import { ConstituentaID, IRSFormCloneData } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { information } from '@/utils/labels';
interface DlgCloneLibraryItemProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgCloneLibraryItemProps {
base: ILibraryItem;
initialLocation: string;
selected: ConstituentaID[];
totalCount: number;
}
function DlgCloneLibraryItem({ hideWindow, base, initialLocation, selected, totalCount }: DlgCloneLibraryItemProps) {
function DlgCloneLibraryItem() {
const { base, initialLocation, selected, totalCount } = useDialogsStore(
state => state.props as DlgCloneLibraryItemProps
);
const router = useConceptNavigation();
const { user } = useAuth();
const [title, setTitle] = useState(cloneTitle(base));
const [alias, setAlias] = useState(base.alias);
const [comment, setComment] = useState(base.comment);
@ -77,7 +82,6 @@ function DlgCloneLibraryItem({ hideWindow, base, initialLocation, selected, tota
return (
<Modal
header='Создание копии концептуальной схемы'
hideWindow={hideWindow}
canSubmit={canSubmit}
submitText='Создать'
onSubmit={handleSubmit}

View File

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

View File

@ -2,20 +2,22 @@
import { useState } from 'react';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import usePartialUpdate from '@/hooks/usePartialUpdate';
import { CstType, ICstCreateData, IRSForm } from '@/models/rsform';
import { generateAlias } from '@/models/rsformAPI';
import { useDialogsStore } from '@/stores/dialogs';
import FormCreateCst from './FormCreateCst';
interface DlgCreateCstProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgCreateCstProps {
initial?: ICstCreateData;
schema: IRSForm;
onCreate: (data: ICstCreateData) => void;
}
function DlgCreateCst({ hideWindow, initial, schema, onCreate }: DlgCreateCstProps) {
function DlgCreateCst() {
const { initial, schema, onCreate } = useDialogsStore(state => state.props as DlgCreateCstProps);
const [validated, setValidated] = useState(false);
const [cstData, updateCstData] = usePartialUpdate(
initial || {
@ -35,7 +37,6 @@ function DlgCreateCst({ hideWindow, initial, schema, onCreate }: DlgCreateCstPro
return (
<Modal
header='Создание конституенты'
hideWindow={hideWindow}
canSubmit={validated}
onSubmit={handleSubmit}
submitText='Создать'

View File

@ -10,13 +10,13 @@ import { useLibrary } from '@/context/LibraryContext';
import { LibraryItemID } from '@/models/library';
import { HelpTopic } from '@/models/miscellaneous';
import { IOperationCreateData, IOperationSchema, OperationID, OperationType } from '@/models/oss';
import { useDialogsStore } from '@/stores/dialogs';
import { describeOperationType, labelOperationType } from '@/utils/labels';
import TabInputOperation from './TabInputOperation';
import TabSynthesisOperation from './TabSynthesisOperation';
interface DlgCreateOperationProps {
hideWindow: () => void;
export interface DlgCreateOperationProps {
oss: IOperationSchema;
onCreate: (data: IOperationCreateData) => void;
initialInputs: OperationID[];
@ -27,7 +27,8 @@ export enum TabID {
SYNTHESIS = 1
}
function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCreateOperationProps) {
function DlgCreateOperation() {
const { oss, onCreate, initialInputs } = useDialogsStore(state => state.props as DlgCreateOperationProps);
const library = useLibrary();
const [activeTab, setActiveTab] = useState(initialInputs.length > 0 ? TabID.SYNTHESIS : TabID.INPUT);
@ -98,7 +99,6 @@ function DlgCreateOperation({ hideWindow, oss, onCreate, initialInputs }: DlgCre
<Modal
header='Создание операции'
submitText='Создать'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className='w-[40rem] px-6 h-[32rem]'

View File

@ -4,21 +4,23 @@ import clsx from 'clsx';
import { useState } from 'react';
import Checkbox from '@/components/ui/Checkbox';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { IVersionCreateData, IVersionInfo } from '@/models/library';
import { nextVersion } from '@/models/libraryAPI';
import { ConstituentaID } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
interface DlgCreateVersionProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgCreateVersionProps {
versions: IVersionInfo[];
onCreate: (data: IVersionCreateData) => void;
selected: ConstituentaID[];
totalCount: number;
}
function DlgCreateVersion({ hideWindow, versions, selected, totalCount, onCreate }: DlgCreateVersionProps) {
function DlgCreateVersion() {
const { versions, selected, totalCount, onCreate } = useDialogsStore(state => state.props as DlgCreateVersionProps);
const [version, setVersion] = useState(versions.length > 0 ? nextVersion(versions[0].version) : '1.0.0');
const [description, setDescription] = useState('');
const [onlySelected, setOnlySelected] = useState(false);
@ -39,7 +41,6 @@ function DlgCreateVersion({ hideWindow, versions, selected, totalCount, onCreate
return (
<Modal
header='Создание версии'
hideWindow={hideWindow}
canSubmit={canSubmit}
onSubmit={handleSubmit}
submitText='Создать'

View File

@ -4,7 +4,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import TabLabel from '@/components/ui/TabLabel';
import { useLibrary } from '@/context/LibraryContext';
import usePartialUpdate from '@/hooks/usePartialUpdate';
@ -12,13 +12,14 @@ import { HelpTopic } from '@/models/miscellaneous';
import { CstType, ICstCreateData, IRSForm } from '@/models/rsform';
import { generateAlias, validateNewAlias } from '@/models/rsformAPI';
import { inferTemplatedType, substituteTemplateArgs } from '@/models/rslangAPI';
import { useDialogsStore } from '@/stores/dialogs';
import { prompts } from '@/utils/labels';
import FormCreateCst from '../DlgCreateCst/FormCreateCst';
import TabArguments, { IArgumentsState } from './TabArguments';
import TabTemplate, { ITemplateState } from './TabTemplate';
interface DlgConstituentaTemplateProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgCstTemplateProps {
schema: IRSForm;
onCreate: (data: ICstCreateData) => void;
insertAfter?: number;
@ -30,7 +31,8 @@ export enum TabID {
CONSTITUENTA = 2
}
function DlgConstituentaTemplate({ hideWindow, schema, onCreate, insertAfter }: DlgConstituentaTemplateProps) {
function DlgCstTemplate() {
const { schema, onCreate, insertAfter } = useDialogsStore(state => state.props as DlgCstTemplateProps);
const { retrieveTemplate } = useLibrary();
const [activeTab, setActiveTab] = useState(TabID.TEMPLATE);
@ -128,7 +130,6 @@ function DlgConstituentaTemplate({ hideWindow, schema, onCreate, insertAfter }:
header='Создание конституенты из шаблона'
submitText='Создать'
className='w-[43rem] h-[35rem] px-6'
hideWindow={hideWindow}
canSubmit={validated}
beforeSubmit={handlePrompt}
onSubmit={handleSubmit}
@ -164,4 +165,4 @@ function DlgConstituentaTemplate({ hideWindow, schema, onCreate, insertAfter }:
);
}
export default DlgConstituentaTemplate;
export default DlgCstTemplate;

View File

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

View File

@ -4,19 +4,22 @@ import clsx from 'clsx';
import { useState } from 'react';
import Checkbox from '@/components/ui/Checkbox';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import { ConstituentaID, IRSForm } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { prefixes } from '@/utils/constants';
import ListConstituents from './ListConstituents';
interface DlgDeleteCstProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgDeleteCstProps {
schema: IRSForm;
selected: ConstituentaID[];
onDelete: (items: ConstituentaID[]) => void;
}
function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstProps) {
function DlgDeleteCst() {
const { selected, schema, onDelete } = useDialogsStore(state => state.props as DlgDeleteCstProps);
const hideDialog = useDialogsStore(state => state.hideDialog);
const [expandOut, setExpandOut] = useState(false);
const expansion: ConstituentaID[] = schema.graph.expandAllOutputs(selected);
const hasInherited = selected.some(
@ -25,7 +28,7 @@ function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstPr
);
function handleSubmit() {
hideWindow();
hideDialog();
if (expandOut) {
onDelete(selected.concat(expansion));
} else {
@ -38,7 +41,6 @@ function DlgDeleteCst({ hideWindow, selected, schema, onDelete }: DlgDeleteCstPr
canSubmit
header='Удаление конституент'
submitText={expandOut ? 'Удалить с зависимыми' : 'Удалить'}
hideWindow={hideWindow}
onSubmit={handleSubmit}
className={clsx('cc-column', 'max-w-[60vw] min-w-[30rem]', 'px-6')}
>

View File

@ -4,22 +4,24 @@ import clsx from 'clsx';
import { useState } from 'react';
import Checkbox from '@/components/ui/Checkbox';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import TextInput from '@/components/ui/TextInput';
import { HelpTopic } from '@/models/miscellaneous';
import { IOperation } from '@/models/oss';
import { IOperation, OperationID } from '@/models/oss';
import { useDialogsStore } from '@/stores/dialogs';
interface DlgDeleteOperationProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgDeleteOperationProps {
target: IOperation;
onSubmit: (keepConstituents: boolean, deleteSchema: boolean) => void;
onSubmit: (targetID: OperationID, keepConstituents: boolean, deleteSchema: boolean) => void;
}
function DlgDeleteOperation({ hideWindow, target, onSubmit }: DlgDeleteOperationProps) {
function DlgDeleteOperation() {
const { target, onSubmit } = useDialogsStore(state => state.props as DlgDeleteOperationProps);
const [keepConstituents, setKeepConstituents] = useState(false);
const [deleteSchema, setDeleteSchema] = useState(false);
function handleSubmit() {
onSubmit(keepConstituents, deleteSchema);
onSubmit(target.id, keepConstituents, deleteSchema);
}
return (
@ -27,7 +29,6 @@ function DlgDeleteOperation({ hideWindow, target, onSubmit }: DlgDeleteOperation
overflowVisible
header='Удаление операции'
submitText='Подтвердить удаление'
hideWindow={hideWindow}
canSubmit={true}
onSubmit={handleSubmit}
className={clsx('w-[35rem]', 'pb-3 px-6 cc-column', 'select-none')}

View File

@ -3,26 +3,26 @@
import clsx from 'clsx';
import { useState } from 'react';
import { useUsers } from '@/backend/users/useUsers';
import { IconRemove } from '@/components/Icons';
import SelectUser from '@/components/select/SelectUser';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal';
import { useUsers } from '@/context/UsersContext';
import { UserID } from '@/models/user';
import { useDialogsStore } from '@/stores/dialogs';
import TableUsers from './TableUsers';
interface DlgEditEditorsProps {
export interface DlgEditEditorsProps {
editors: UserID[];
setEditors: (newValue: UserID[]) => void;
hideWindow: () => void;
}
function DlgEditEditors({ hideWindow, editors, setEditors }: DlgEditEditorsProps) {
function DlgEditEditors() {
const { editors, setEditors } = useDialogsStore(state => state.props as DlgEditEditorsProps);
const [selected, setSelected] = useState<UserID[]>(editors);
const { users } = useUsers();
const filtered = users.filter(user => !selected.includes(user.id));
function handleSubmit() {
setEditors(selected);
@ -41,7 +41,6 @@ function DlgEditEditors({ hideWindow, editors, setEditors }: DlgEditEditorsProps
canSubmit
header='Список редакторов'
submitText='Сохранить список'
hideWindow={hideWindow}
className='flex flex-col w-[35rem] px-6 gap-3 pb-6'
onSubmit={handleSubmit}
>
@ -61,7 +60,12 @@ function DlgEditEditors({ hideWindow, editors, setEditors }: DlgEditEditorsProps
<div className='flex items-center gap-3'>
<Label text='Добавить' />
<SelectUser items={filtered} value={undefined} onSelectValue={onAddEditor} className='w-[25rem]' />
<SelectUser
filter={id => !selected.includes(id)}
value={undefined}
onSelectValue={onAddEditor}
className='w-[25rem]'
/>
</div>
</Modal>
);

View File

@ -19,13 +19,13 @@ import {
} from '@/models/oss';
import { SubstitutionValidator } from '@/models/ossAPI';
import { ConstituentaID } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import TabArguments from './TabArguments';
import TabOperation from './TabOperation';
import TabSynthesis from './TabSynthesis';
interface DlgEditOperationProps {
hideWindow: () => void;
export interface DlgEditOperationProps {
oss: IOperationSchema;
target: IOperation;
onSubmit: (data: IOperationUpdateData) => void;
@ -37,7 +37,8 @@ export enum TabID {
SUBSTITUTION = 2
}
function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperationProps) {
function DlgEditOperation() {
const { oss, target, onSubmit } = useDialogsStore(state => state.props as DlgEditOperationProps);
const [activeTab, setActiveTab] = useState(TabID.CARD);
const [alias, setAlias] = useState(target.alias);
@ -142,7 +143,6 @@ function DlgEditOperation({ hideWindow, oss, target, onSubmit }: DlgEditOperatio
<Modal
header='Редактирование операции'
submitText='Сохранить'
hideWindow={hideWindow}
canSubmit={canSubmit}
onSubmit={handleSubmit}
className='w-[40rem] px-6 h-[32rem]'

View File

@ -9,6 +9,7 @@ import TabLabel from '@/components/ui/TabLabel';
import { ReferenceType } from '@/models/language';
import { HelpTopic } from '@/models/miscellaneous';
import { IRSForm } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { labelReferenceType } from '@/utils/labels';
import TabEntityReference from './TabEntityReference';
@ -22,8 +23,7 @@ export interface IReferenceInputState {
basePosition: number;
}
interface DlgEditReferenceProps {
hideWindow: () => void;
export interface DlgEditReferenceProps {
schema: IRSForm;
initial: IReferenceInputState;
onSave: (newRef: string) => void;
@ -34,7 +34,8 @@ export enum TabID {
SYNTACTIC = 1
}
function DlgEditReference({ hideWindow, schema, initial, onSave }: DlgEditReferenceProps) {
function DlgEditReference() {
const { schema, initial, onSave } = useDialogsStore(state => state.props as DlgEditReferenceProps);
const [activeTab, setActiveTab] = useState(initial.type === ReferenceType.ENTITY ? TabID.ENTITY : TabID.SYNTACTIC);
const [reference, setReference] = useState('');
const [isValid, setIsValid] = useState(false);
@ -43,7 +44,6 @@ function DlgEditReference({ hideWindow, schema, initial, onSave }: DlgEditRefere
<Modal
header='Редактирование ссылки'
submitText='Сохранить ссылку'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={() => onSave(reference)}
className='w-[40rem] px-6 h-[32rem]'

View File

@ -7,21 +7,21 @@ import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useRSForm } from '@/context/RSFormContext';
import { IVersionData, IVersionInfo, VersionID } from '@/models/library';
import { useDialogsStore } from '@/stores/dialogs';
import TableVersions from './TableVersions';
interface DlgEditVersionsProps {
hideWindow: () => void;
export interface DlgEditVersionsProps {
versions: IVersionInfo[];
onDelete: (versionID: VersionID) => void;
onUpdate: (versionID: VersionID, data: IVersionData) => void;
}
function DlgEditVersions({ hideWindow, versions, onDelete, onUpdate }: DlgEditVersionsProps) {
const { processing } = useRSForm();
function DlgEditVersions() {
const { versions, onDelete, onUpdate } = useDialogsStore(state => state.props as DlgEditVersionsProps);
const [selected, setSelected] = useState<IVersionInfo | undefined>(undefined);
const processing = false; // TODO: fix processing hook and versions update
const [version, setVersion] = useState('');
const [description, setDescription] = useState('');
@ -54,12 +54,7 @@ function DlgEditVersions({ hideWindow, versions, onDelete, onUpdate }: DlgEditVe
}, [selected]);
return (
<Modal
readonly
header='Редактирование версий'
hideWindow={hideWindow}
className='flex flex-col w-[40rem] px-6 gap-3 pb-6'
>
<Modal readonly header='Редактирование версий' className='flex flex-col w-[40rem] px-6 gap-3 pb-6'>
<TableVersions
processing={processing}
items={versions}

View File

@ -14,18 +14,19 @@ import { Grammeme, ITextRequest, IWordForm, IWordFormPlain } from '@/models/lang
import { parseGrammemes, wordFormEquals } from '@/models/languageAPI';
import { HelpTopic } from '@/models/miscellaneous';
import { IConstituenta, TermForm } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { prompts } from '@/utils/labels';
import { IGrammemeOption, SelectorGrammemes, SelectorGrammemesList } from '@/utils/selectors';
import TableWordForms from './TableWordForms';
interface DlgEditWordFormsProps {
hideWindow: () => void;
export interface DlgEditWordFormsProps {
target: IConstituenta;
onSave: (data: TermForm[]) => void;
}
function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps) {
function DlgEditWordForms() {
const { target, onSave } = useDialogsStore(state => state.props as DlgEditWordFormsProps);
const textProcessor = useConceptText();
const [term, setTerm] = useState('');
@ -123,7 +124,6 @@ function DlgEditWordForms({ hideWindow, target, onSave }: DlgEditWordFormsProps)
<Modal
canSubmit
header='Редактирование словоформ'
hideWindow={hideWindow}
submitText='Сохранить'
onSubmit={handleSubmit}
className='flex flex-col w-[40rem] px-6'

View File

@ -1,24 +1,25 @@
'use client';
import Checkbox from '@/components/ui/Checkbox';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import usePartialUpdate from '@/hooks/usePartialUpdate';
import { GraphFilterParams } from '@/models/miscellaneous';
import { CstType } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { labelCstType } from '@/utils/labels';
interface DlgGraphParamsProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgGraphParamsProps {
initial: GraphFilterParams;
onConfirm: (params: GraphFilterParams) => void;
}
function DlgGraphParams({ hideWindow, initial, onConfirm }: DlgGraphParamsProps) {
function DlgGraphParams() {
const { initial, onConfirm } = useDialogsStore(state => state.props as DlgGraphParamsProps);
const [params, updateParams] = usePartialUpdate(initial);
return (
<Modal
canSubmit
hideWindow={hideWindow}
header='Настройки графа термов'
onSubmit={() => onConfirm(params)}
submitText='Применить'

View File

@ -4,18 +4,19 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import TabLabel from '@/components/ui/TabLabel';
import useRSFormDetails from '@/hooks/useRSFormDetails';
import { LibraryItemID } from '@/models/library';
import { ICstSubstitute } from '@/models/oss';
import { ConstituentaID, IInlineSynthesisData, IRSForm } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import TabConstituents from './TabConstituents';
import TabSchema from './TabSchema';
import TabSubstitutions from './TabSubstitutions';
interface DlgInlineSynthesisProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgInlineSynthesisProps {
receiver: IRSForm;
onInlineSynthesis: (data: IInlineSynthesisData) => void;
}
@ -26,7 +27,8 @@ export enum TabID {
SUBSTITUTIONS = 2
}
function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInlineSynthesisProps) {
function DlgInlineSynthesis() {
const { receiver, onInlineSynthesis } = useDialogsStore(state => state.props as DlgInlineSynthesisProps);
const [activeTab, setActiveTab] = useState(TabID.SCHEMA);
const [donorID, setDonorID] = useState<LibraryItemID | undefined>(undefined);
@ -60,7 +62,6 @@ function DlgInlineSynthesis({ hideWindow, receiver, onInlineSynthesis }: DlgInli
header='Импорт концептуальной схем'
submitText='Добавить конституенты'
className='w-[40rem] h-[33rem] px-6'
hideWindow={hideWindow}
canSubmit={validated}
onSubmit={handleSubmit}
>

View File

@ -7,7 +7,7 @@ import { RelocateUpIcon } from '@/components/DomainIcons';
import PickMultiConstituenta from '@/components/select/PickMultiConstituenta';
import SelectLibraryItem from '@/components/select/SelectLibraryItem';
import MiniButton from '@/components/ui/MiniButton';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import DataLoader from '@/components/wrap/DataLoader';
import { useLibrary } from '@/context/LibraryContext';
import useRSFormDetails from '@/hooks/useRSFormDetails';
@ -16,15 +16,17 @@ import { HelpTopic } from '@/models/miscellaneous';
import { ICstRelocateData, IOperation, IOperationSchema } from '@/models/oss';
import { getRelocateCandidates } from '@/models/ossAPI';
import { ConstituentaID } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { prefixes } from '@/utils/constants';
interface DlgRelocateConstituentsProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgRelocateConstituentsProps {
oss: IOperationSchema;
initialTarget?: IOperation;
onSubmit: (data: ICstRelocateData) => void;
}
function DlgRelocateConstituents({ oss, hideWindow, initialTarget, onSubmit }: DlgRelocateConstituentsProps) {
function DlgRelocateConstituents() {
const { oss, initialTarget, onSubmit } = useDialogsStore(state => state.props as DlgRelocateConstituentsProps);
const library = useLibrary();
const [directionUp, setDirectionUp] = useState(true);
@ -88,7 +90,6 @@ function DlgRelocateConstituents({ oss, hideWindow, initialTarget, onSubmit }: D
<Modal
header='Перенос конституент'
submitText='Переместить'
hideWindow={hideWindow}
canSubmit={isValid}
onSubmit={handleSubmit}
className={clsx('w-[40rem] h-[33rem]', 'py-3 px-6')}

View File

@ -3,25 +3,26 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import SelectSingle from '@/components/ui/SelectSingle';
import TextInput from '@/components/ui/TextInput';
import { useRSForm } from '@/context/RSFormContext';
import usePartialUpdate from '@/hooks/usePartialUpdate';
import { HelpTopic } from '@/models/miscellaneous';
import { CstType, ICstRenameData } from '@/models/rsform';
import { CstType, ICstRenameData, IRSForm } from '@/models/rsform';
import { generateAlias, validateNewAlias } from '@/models/rsformAPI';
import { useDialogsStore } from '@/stores/dialogs';
import { labelCstType } from '@/utils/labels';
import { SelectorCstType } from '@/utils/selectors';
interface DlgRenameCstProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgRenameCstProps {
schema: IRSForm;
initial: ICstRenameData;
allowChangeType: boolean;
onRename: (data: ICstRenameData) => void;
}
function DlgRenameCst({ hideWindow, initial, allowChangeType, onRename }: DlgRenameCstProps) {
const { schema } = useRSForm();
function DlgRenameCst() {
const { schema, initial, allowChangeType, onRename } = useDialogsStore(state => state.props as DlgRenameCstProps);
const [validated, setValidated] = useState(false);
const [cstData, updateData] = usePartialUpdate(initial);
@ -42,7 +43,6 @@ function DlgRenameCst({ hideWindow, initial, allowChangeType, onRename }: DlgRen
header='Переименование конституенты'
submitText='Переименовать'
submitInvalidTooltip='Введите незанятое имя, соответствующее типу'
hideWindow={hideWindow}
canSubmit={validated}
onSubmit={() => onRename(cstData)}
className={clsx('w-[30rem]', 'py-6 pr-3 pl-6 flex gap-3 justify-center items-center ')}

View File

@ -3,19 +3,21 @@
import { useState } from 'react';
import { ReactFlowProvider } from 'reactflow';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import Overlay from '@/components/ui/Overlay';
import { HelpTopic } from '@/models/miscellaneous';
import { SyntaxTree } from '@/models/rslang';
import { useDialogsStore } from '@/stores/dialogs';
import ASTFlow from './ASTFlow';
interface DlgShowASTProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgShowASTProps {
syntaxTree: SyntaxTree;
expression: string;
}
function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
function DlgShowAST() {
const { syntaxTree, expression } = useDialogsStore(state => state.props as DlgShowASTProps);
const [hoverID, setHoverID] = useState<number | undefined>(undefined);
const hoverNode = syntaxTree.find(node => node.uid === hoverID);
@ -24,7 +26,6 @@ function DlgShowAST({ hideWindow, syntaxTree, expression }: DlgShowASTProps) {
return (
<Modal
readonly
hideWindow={hideWindow}
className='flex flex-col justify-stretch w-[calc(100dvw-3rem)] h-[calc(100dvh-6rem)]'
helpTopic={HelpTopic.UI_FORMULA_TREE}
>

View File

@ -3,19 +3,17 @@
import clsx from 'clsx';
import { QRCodeSVG } from 'qrcode.react';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import { useDialogsStore } from '@/stores/dialogs';
interface DlgShowQRProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgShowQRProps {
target: string;
}
function DlgShowQR({ hideWindow, target }: DlgShowQRProps) {
function DlgShowQR() {
const { target } = useDialogsStore(state => state.props as DlgShowQRProps);
return (
<Modal
readonly
hideWindow={hideWindow}
className={clsx('w-[30rem]', 'py-12 pr-3 pl-6 flex gap-3 justify-center items-center')}
>
<Modal readonly className={clsx('w-[30rem]', 'py-12 pr-3 pl-6 flex gap-3 justify-center items-center')}>
<div className='bg-[#ffffff] p-4 border'>
<QRCodeSVG value={target} size={256} />
</div>

View File

@ -3,19 +3,22 @@
import { toast } from 'react-toastify';
import { ReactFlowProvider } from 'reactflow';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import { HelpTopic } from '@/models/miscellaneous';
import { ITypeInfo } from '@/models/rslang';
import { TMGraph } from '@/models/TMGraph';
import { useDialogsStore } from '@/stores/dialogs';
import { errors } from '@/utils/labels';
import MGraphFlow from './MGraphFlow';
interface DlgShowTypeGraphProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgShowTypeGraphProps {
items: ITypeInfo[];
}
function DlgShowTypeGraph({ hideWindow, items }: DlgShowTypeGraphProps) {
function DlgShowTypeGraph() {
const { items } = useDialogsStore(state => state.props as DlgShowTypeGraphProps);
const hideDialog = useDialogsStore(state => state.hideDialog);
const graph = (() => {
const result = new TMGraph();
items.forEach(item => result.addConstituenta(item.alias, item.result, item.args));
@ -24,7 +27,7 @@ function DlgShowTypeGraph({ hideWindow, items }: DlgShowTypeGraphProps) {
if (graph.nodes.length === 0) {
toast.error(errors.typeStructureFailed);
hideWindow();
hideDialog();
return null;
}
@ -32,7 +35,6 @@ function DlgShowTypeGraph({ hideWindow, items }: DlgShowTypeGraphProps) {
<Modal
header='Граф ступеней'
readonly
hideWindow={hideWindow}
className='flex flex-col justify-stretch w-[calc(100dvw-3rem)] h-[calc(100dvh-6rem)]'
helpTopic={HelpTopic.UI_TYPE_GRAPH}
>

View File

@ -4,18 +4,20 @@ import clsx from 'clsx';
import { useState } from 'react';
import PickSubstitutions from '@/components/select/PickSubstitutions';
import Modal, { ModalProps } from '@/components/ui/Modal';
import Modal from '@/components/ui/Modal';
import { HelpTopic } from '@/models/miscellaneous';
import { ICstSubstitute, ICstSubstituteData } from '@/models/oss';
import { IRSForm } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { prefixes } from '@/utils/constants';
interface DlgSubstituteCstProps extends Pick<ModalProps, 'hideWindow'> {
export interface DlgSubstituteCstProps {
schema: IRSForm;
onSubstitute: (data: ICstSubstituteData) => void;
}
function DlgSubstituteCst({ hideWindow, onSubstitute, schema }: DlgSubstituteCstProps) {
function DlgSubstituteCst() {
const { onSubstitute, schema } = useDialogsStore(state => state.props as DlgSubstituteCstProps);
const [substitutions, setSubstitutions] = useState<ICstSubstitute[]>([]);
const canSubmit = substitutions.length > 0;
@ -31,7 +33,6 @@ function DlgSubstituteCst({ hideWindow, onSubstitute, schema }: DlgSubstituteCst
header='Отождествление'
submitText='Отождествить'
submitInvalidTooltip='Выберите две различные конституенты'
hideWindow={hideWindow}
canSubmit={canSubmit}
onSubmit={handleSubmit}
className={clsx('w-[40rem]', 'px-6 pb-3')}

View File

@ -6,16 +6,16 @@ import { toast } from 'react-toastify';
import Checkbox from '@/components/ui/Checkbox';
import FileInput from '@/components/ui/FileInput';
import Modal from '@/components/ui/Modal';
import { useRSForm } from '@/context/RSFormContext';
import { IRSFormUploadData } from '@/models/rsform';
import { useDialogsStore } from '@/stores/dialogs';
import { EXTEOR_TRS_FILE } from '@/utils/constants';
interface DlgUploadRSFormProps {
hideWindow: () => void;
export interface DlgUploadRSFormProps {
upload: (data: IRSFormUploadData, callback: () => void) => void;
}
function DlgUploadRSForm({ hideWindow }: DlgUploadRSFormProps) {
const { upload } = useRSForm();
function DlgUploadRSForm() {
const { upload } = useDialogsStore(state => state.props as DlgUploadRSFormProps);
const [loadMetadata, setLoadMetadata] = useState(false);
const [file, setFile] = useState<File | undefined>();
@ -42,7 +42,6 @@ function DlgUploadRSForm({ hideWindow }: DlgUploadRSFormProps) {
return (
<Modal
header='Импорт схемы из Экстеора'
hideWindow={hideWindow}
canSubmit={!!file}
onSubmit={handleSubmit}
submitText='Загрузить'

View File

@ -1,31 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { storage } from '@/utils/constants';
function useLocalStorage<ValueType>(key: string, defaultValue: ValueType | (() => ValueType)) {
const prefixedKey = `${storage.PREFIX}${key}`;
const [value, setValue] = useState<ValueType>(() => {
const loadedJson = localStorage.getItem(prefixedKey);
if (loadedJson != null) {
return JSON.parse(loadedJson) as ValueType;
} else if (typeof defaultValue === 'function') {
return (defaultValue as () => ValueType)();
} else {
return defaultValue;
}
});
useEffect(() => {
if (value === undefined) {
localStorage.removeItem(prefixedKey);
} else {
localStorage.setItem(prefixedKey, JSON.stringify(value));
}
}, [prefixedKey, value]);
return [value, setValue] as [ValueType, typeof setValue];
}
export default useLocalStorage;

View File

@ -2,15 +2,15 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/backend/auth/useAuth';
import { getOssDetails } from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext';
import { IOperationSchema, IOperationSchemaData } from '@/models/oss';
import { OssLoader } from '@/models/OssLoader';
function useOssDetails({ target }: { target?: string }) {
const { loading: userLoading } = useAuth();
const { isLoading: userLoading } = useAuth();
const library = useLibrary();
const [schema, setInner] = useState<IOperationSchema | undefined>(undefined);
const [loading, setLoading] = useState(target != undefined);

View File

@ -2,14 +2,14 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/backend/auth/useAuth';
import { getRSFormDetails } from '@/backend/rsforms';
import { type ErrorData } from '@/components/info/InfoError';
import { useAuth } from '@/context/AuthContext';
import { IRSForm, IRSFormData } from '@/models/rsform';
import { RSFormLoader } from '@/models/RSFormLoader';
function useRSFormDetails({ target, version }: { target?: string; version?: string }) {
const { loading: userLoading } = useAuth();
const { isLoading: userLoading } = useAuth();
const [schema, setInnerSchema] = useState<IRSForm | undefined>(undefined);
const [loading, setLoading] = useState(target != undefined);
const [error, setError] = useState<ErrorData>(undefined);

View File

@ -224,3 +224,32 @@ export interface Position2D {
x: number;
y: number;
}
/**
* Represents global dialog.
*/
export enum DialogType {
CONSTITUENTA_TEMPLATE = 1,
CREATE_CONSTITUENTA,
CREATE_OPERATION,
DELETE_CONSTITUENTA,
EDIT_EDITORS,
EDIT_OPERATION,
EDIT_REFERENCE,
EDIT_VERSIONS,
EDIT_WORD_FORMS,
INLINE_SYNTHESIS,
SHOW_AST,
SHOW_TYPE_GRAPH,
CHANGE_INPUT_SCHEMA,
CHANGE_LOCATION,
CLONE_LIBRARY_ITEM,
CREATE_VERSION,
DELETE_OPERATION,
GRAPH_PARAMETERS,
RELOCATE_CONSTITUENTS,
RENAME_CONSTITUENTA,
SHOW_QR_CODE,
SUBSTITUTE_CONSTITUENTS,
UPLOAD_RSFORM
}

View File

@ -26,35 +26,9 @@ export interface IUser {
* Represents CurrentUser information.
*/
export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> {
subscriptions: LibraryItemID[];
editor: LibraryItemID[];
}
/**
* Represents login data, used to authenticate users.
*/
export interface IUserLoginData extends Pick<IUser, 'username'> {
password: string;
}
/**
* Represents password reset data.
*/
export interface IResetPasswordData {
password: string;
token: string;
}
/**
* Represents password token data.
*/
export interface IPasswordTokenData extends Pick<IResetPasswordData, 'token'> {}
/**
* Represents password reset request data.
*/
export interface IRequestPasswordData extends Pick<IUser, 'email'> {}
/**
* Represents signup data, used to create new users.
*/
@ -63,11 +37,6 @@ export interface IUserSignupData extends Omit<IUser, 'is_staff' | 'id'> {
password2: string;
}
/**
* Represents user data, intended to update user profile in persistent storage.
*/
export interface IUserUpdateData extends Omit<IUser, 'is_staff' | 'id'> {}
/**
* Represents user profile for viewing and editing {@link IUser}.
*/
@ -78,14 +47,6 @@ export interface IUserProfile extends Omit<IUser, 'is_staff'> {}
*/
export interface IUserInfo extends Omit<IUserProfile, 'email' | 'username'> {}
/**
* Represents data needed to update password for current user.
*/
export interface IUserUpdatePassword {
old_password: string;
new_password: string;
}
/**
* Represents target {@link User}.
*/
@ -103,7 +64,7 @@ export interface ITargetUsers {
/**
* Represents user access mode.
*/
export enum UserLevel {
export enum UserRole {
READER = 0,
EDITOR,
OWNER,

View File

@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { VisibilityIcon } from '@/components/DomainIcons';
import { IconDownload } from '@/components/Icons';
import InfoError from '@/components/info/InfoError';
@ -19,22 +20,23 @@ import Overlay from '@/components/ui/Overlay';
import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { ILibraryCreateData } from '@/models/library';
import { combineLocation, validateLocation } from '@/models/libraryAPI';
import { useLibrarySearchStore } from '@/stores/librarySearch';
import { EXTEOR_TRS_FILE } from '@/utils/constants';
import { information } from '@/utils/labels';
function FormCreateItem() {
const router = useConceptNavigation();
const options = useConceptOptions();
const { user } = useAuth();
const { createItem, processingError, setProcessingError, processing, folders } = useLibrary();
const searchLocation = useLibrarySearchStore(state => state.location);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const [itemType, setItemType] = useState(LibraryItemType.RSFORM);
const [title, setTitle] = useState('');
const [alias, setAlias] = useState('');
@ -81,7 +83,7 @@ function FormCreateItem() {
file: file,
fileName: file?.name
};
options.setLocation(location);
setSearchLocation(location);
createItem(data, newItem => {
toast.success(information.newLibraryItem);
if (itemType == LibraryItemType.RSFORM) {
@ -108,11 +110,11 @@ function FormCreateItem() {
}, []);
useEffect(() => {
if (!options.location) {
if (!searchLocation) {
return;
}
handleSelectLocation(options.location);
}, [options.location, handleSelectLocation]);
handleSelectLocation(searchLocation);
}, [searchLocation, handleSelectLocation]);
useEffect(() => {
if (itemType !== LibraryItemType.RSFORM) {

View File

@ -3,18 +3,18 @@
import { useEffect } from 'react';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useAppLayoutStore, useFitHeight } from '@/stores/appLayout';
import { resources } from '@/utils/constants';
function DatabaseSchemaPage() {
const { calculateHeight, setNoFooter } = useConceptOptions();
const hideFooter = useAppLayoutStore(state => state.hideFooter);
const panelHeight = calculateHeight('0px');
const panelHeight = useFitHeight('0px');
useEffect(() => {
setNoFooter(true);
return () => setNoFooter(false);
}, [setNoFooter]);
hideFooter(true);
return () => hideFooter(false);
}, [hideFooter]);
return (
<div className='cc-fade-in flex justify-center overflow-hidden' style={{ maxHeight: panelHeight }}>

View File

@ -1,17 +1,17 @@
import { useEffect } from 'react';
import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import Loader from '@/components/ui/Loader';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { PARAMETER } from '@/utils/constants';
function HomePage() {
const router = useConceptNavigation();
const { user, loading } = useAuth();
const { user, isLoading } = useAuth();
useEffect(() => {
if (!loading) {
if (!isLoading) {
if (!user) {
setTimeout(() => {
router.replace(urls.manuals);
@ -22,7 +22,7 @@ function HomePage() {
}, PARAMETER.refreshTimeout);
}
}
}, [router, user, loading]);
}, [router, user, isLoading]);
return <Loader />;
}

View File

@ -1,24 +1,19 @@
'use client';
import fileDownload from 'js-file-download';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { IconCSV } from '@/components/Icons';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import DataLoader from '@/components/wrap/DataLoader';
import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useLibrary } from '@/context/LibraryContext';
import DlgChangeLocation from '@/dialogs/DlgChangeLocation';
import useLocalStorage from '@/hooks/useLocalStorage';
import { ILibraryItem, IRenameLocationData, LocationHead } from '@/models/library';
import { ILibraryFilter } from '@/models/miscellaneous';
import { UserID } from '@/models/user';
import { storage } from '@/utils/constants';
import { IRenameLocationData } from '@/models/library';
import { useAppLayoutStore } from '@/stores/appLayout';
import { useDialogsStore } from '@/stores/dialogs';
import { useLibraryFilter, useLibrarySearchStore } from '@/stores/librarySearch';
import { information } from '@/utils/labels';
import { convertToCSV, toggleTristateFlag } from '@/utils/utils';
import { convertToCSV } from '@/utils/utils';
import TableLibraryItems from './TableLibraryItems';
import ToolbarSearch from './ToolbarSearch';
@ -26,91 +21,29 @@ import ViewSideLocation from './ViewSideLocation';
function LibraryPage() {
const library = useLibrary();
const { user } = useAuth();
const [items, setItems] = useState<ILibraryItem[]>([]);
const options = useConceptOptions();
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const [query, setQuery] = useState('');
const [path, setPath] = useState('');
const folderMode = useLibrarySearchStore(state => state.folderMode);
const location = useLibrarySearchStore(state => state.location);
const setLocation = useLibrarySearchStore(state => state.setLocation);
const [head, setHead] = useLocalStorage<LocationHead | undefined>(storage.librarySearchHead, undefined);
const [subfolders, setSubfolders] = useLocalStorage<boolean>(storage.librarySearchSubfolders, false);
const [isVisible, setIsVisible] = useLocalStorage<boolean | undefined>(storage.librarySearchVisible, true);
const [isOwned, setIsOwned] = useLocalStorage<boolean | undefined>(storage.librarySearchOwned, undefined);
const [isEditor, setIsEditor] = useLocalStorage<boolean | undefined>(storage.librarySearchEditor, undefined);
const [filterUser, setFilterUser] = useLocalStorage<UserID | undefined>(storage.librarySearchUser, undefined);
const [showRenameLocation, setShowRenameLocation] = useState(false);
const filter = useLibraryFilter();
const items = library.applyFilter(filter);
const filter: ILibraryFilter = useMemo(
() => ({
head: head,
path: path,
query: query,
isEditor: user ? isEditor : undefined,
isOwned: user ? isOwned : undefined,
isVisible: user ? isVisible : true,
folderMode: options.folderMode,
subfolders: subfolders,
location: options.location,
filterUser: filterUser
}),
[
head,
path,
query,
isEditor,
isOwned,
isVisible,
user,
options.folderMode,
options.location,
subfolders,
filterUser
]
);
const showChangeLocation = useDialogsStore(state => state.showChangeLocation);
const hasCustomFilter =
!!filter.path ||
!!filter.query ||
filter.head !== undefined ||
filter.isEditor !== undefined ||
filter.isOwned !== undefined ||
filter.isVisible !== true ||
filter.filterUser !== undefined ||
!!filter.location;
function handleRenameLocation(newLocation: string) {
const data: IRenameLocationData = {
target: location,
new_location: newLocation
};
library.renameLocation(data, () => {
setLocation(newLocation);
toast.success(information.locationRenamed);
});
}
useEffect(() => {
setItems(library.applyFilter(filter));
}, [library, library.items.length, filter]);
const toggleFolderMode = () => options.setFolderMode(prev => !prev);
const resetFilter = useCallback(() => {
setQuery('');
setPath('');
setHead(undefined);
setIsVisible(true);
setIsOwned(undefined);
setIsEditor(undefined);
setFilterUser(undefined);
options.setLocation('');
}, [setHead, setIsVisible, setIsOwned, setIsEditor, setFilterUser, options]);
const handleRenameLocation = useCallback(
(newLocation: string) => {
const data: IRenameLocationData = {
target: options.location,
new_location: newLocation
};
library.renameLocation(data, () => {
options.setLocation(newLocation);
toast.success(information.locationRenamed);
});
},
[options, library]
);
const handleDownloadCSV = useCallback(() => {
function handleDownloadCSV() {
if (items.length === 0) {
toast.error(information.noDataToExport);
return;
@ -121,19 +54,12 @@ function LibraryPage() {
} catch (error) {
console.error(error);
}
}, [items]);
}
return (
<DataLoader isLoading={library.loading} error={library.loadingError} hasNoData={library.items.length === 0}>
{showRenameLocation ? (
<DlgChangeLocation
initial={options.location}
onChangeLocation={handleRenameLocation}
hideWindow={() => setShowRenameLocation(false)}
/>
) : null}
<Overlay
position={options.noNavigation ? 'top-[0.25rem] right-[3rem]' : 'top-[0.25rem] right-0'}
position={noNavigation ? 'top-[0.25rem] right-[3rem]' : 'top-[0.25rem] right-0'}
layer='z-tooltip'
className='cc-animate-position'
>
@ -143,47 +69,16 @@ function LibraryPage() {
onClick={handleDownloadCSV}
/>
</Overlay>
<ToolbarSearch
total={library.items.length ?? 0}
filtered={items.length}
hasCustomFilter={hasCustomFilter}
query={query}
onChangeQuery={setQuery}
path={path}
onChangePath={setPath}
head={head}
onChangeHead={setHead}
isVisible={isVisible}
isOwned={isOwned}
toggleOwned={() => setIsOwned(prev => toggleTristateFlag(prev))}
toggleVisible={() => setIsVisible(prev => toggleTristateFlag(prev))}
isEditor={isEditor}
toggleEditor={() => setIsEditor(prev => toggleTristateFlag(prev))}
filterUser={filterUser}
onChangeFilterUser={setFilterUser}
resetFilter={resetFilter}
folderMode={options.folderMode}
toggleFolderMode={toggleFolderMode}
/>
<ToolbarSearch total={library.items.length ?? 0} filtered={items.length} />
<div className='cc-fade-in flex'>
<ViewSideLocation
isVisible={options.folderMode}
activeLocation={options.location}
onChangeActiveLocation={options.setLocation}
subfolders={subfolders}
isVisible={folderMode}
folderTree={library.folders}
toggleFolderMode={toggleFolderMode}
toggleSubfolders={() => setSubfolders(prev => !prev)}
onRenameLocation={() => setShowRenameLocation(true)}
onRenameLocation={() => showChangeLocation({ initial: location, onChangeLocation: handleRenameLocation })}
/>
<TableLibraryItems
resetQuery={resetFilter}
items={items}
folderMode={options.folderMode}
toggleFolderMode={toggleFolderMode}
/>
<TableLibraryItems items={items} />
</div>
</DataLoader>
);

View File

@ -5,6 +5,7 @@ import { useLayoutEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { urls } from '@/app/urls';
import { useLabelUser } from '@/backend/users/useLabelUser';
import { IconFolderTree } from '@/components/Icons';
import BadgeLocation from '@/components/info/BadgeLocation';
import { CProps } from '@/components/props';
@ -12,30 +13,31 @@ import DataTable, { createColumnHelper, IConditionalStyle, VisibilityState } fro
import FlexColumn from '@/components/ui/FlexColumn';
import MiniButton from '@/components/ui/MiniButton';
import TextURL from '@/components/ui/TextURL';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useUsers } from '@/context/UsersContext';
import useLocalStorage from '@/hooks/useLocalStorage';
import useWindowSize from '@/hooks/useWindowSize';
import { ILibraryItem, LibraryItemType } from '@/models/library';
import { useFitHeight } from '@/stores/appLayout';
import { useLibrarySearchStore } from '@/stores/librarySearch';
import { usePreferencesStore } from '@/stores/preferences';
import { APP_COLORS } from '@/styling/color';
import { storage } from '@/utils/constants';
interface TableLibraryItemsProps {
items: ILibraryItem[];
resetQuery: () => void;
folderMode: boolean;
toggleFolderMode: () => void;
}
const columnHelper = createColumnHelper<ILibraryItem>();
function TableLibraryItems({ items, resetQuery, folderMode, toggleFolderMode }: TableLibraryItemsProps) {
function TableLibraryItems({ items }: TableLibraryItemsProps) {
const router = useConceptNavigation();
const intl = useIntl();
const { getUserLabel } = useUsers();
const { calculateHeight } = useConceptOptions();
const [itemsPerPage, setItemsPerPage] = useLocalStorage<number>(storage.libraryPagination, 50);
const getUserLabel = useLabelUser();
const folderMode = useLibrarySearchStore(state => state.folderMode);
const toggleFolderMode = useLibrarySearchStore(state => state.toggleFolderMode);
const resetFilter = useLibrarySearchStore(state => state.resetFilter);
const itemsPerPage = usePreferencesStore(state => state.libraryPagination);
const setItemsPerPage = usePreferencesStore(state => state.setLibraryPagination);
function handleOpenItem(item: ILibraryItem, event: CProps.EventMouse) {
const selection = window.getSelection();
@ -140,7 +142,7 @@ function TableLibraryItems({ items, resetQuery, folderMode, toggleFolderMode }:
})
];
const tableHeight = calculateHeight('2.2rem');
const tableHeight = useFitHeight('2.2rem');
const conditionalRowStyles: IConditionalStyle<ILibraryItem>[] = [
{
@ -164,7 +166,7 @@ function TableLibraryItems({ items, resetQuery, folderMode, toggleFolderMode }:
<p>Список схем пуст</p>
<p className='flex gap-6'>
<TextURL text='Создать схему' href='/library/create' />
<TextURL text='Очистить фильтр' onClick={resetQuery} />
<TextURL text='Очистить фильтр' onClick={resetFilter} />
</p>
</FlexColumn>
}

View File

@ -19,10 +19,9 @@ import DropdownButton from '@/components/ui/DropdownButton';
import MiniButton from '@/components/ui/MiniButton';
import SearchBar from '@/components/ui/SearchBar';
import SelectorButton from '@/components/ui/SelectorButton';
import { useUsers } from '@/context/UsersContext';
import useDropdown from '@/hooks/useDropdown';
import { LocationHead } from '@/models/library';
import { UserID } from '@/models/user';
import { useHasCustomFilter, useLibrarySearchStore } from '@/stores/librarySearch';
import { prefixes } from '@/utils/constants';
import { describeLocationHead, labelLocationHead } from '@/utils/labels';
import { tripleToggleColor } from '@/utils/utils';
@ -30,65 +29,37 @@ import { tripleToggleColor } from '@/utils/utils';
interface ToolbarSearchProps {
total: number;
filtered: number;
hasCustomFilter: boolean;
query: string;
onChangeQuery: (newValue: string) => void;
path: string;
onChangePath: (newValue: string) => void;
head: LocationHead | undefined;
onChangeHead: (newValue: LocationHead | undefined) => void;
folderMode: boolean;
toggleFolderMode: () => void;
isVisible: boolean | undefined;
toggleVisible: () => void;
isOwned: boolean | undefined;
toggleOwned: () => void;
isEditor: boolean | undefined;
toggleEditor: () => void;
filterUser: UserID | undefined;
onChangeFilterUser: (newValue: UserID | undefined) => void;
resetFilter: () => void;
}
function ToolbarSearch({
total,
filtered,
hasCustomFilter,
query,
onChangeQuery,
path,
onChangePath,
head,
onChangeHead,
folderMode,
toggleFolderMode,
isVisible,
toggleVisible,
isOwned,
toggleOwned,
isEditor,
toggleEditor,
filterUser,
onChangeFilterUser,
resetFilter
}: ToolbarSearchProps) {
function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
const headMenu = useDropdown();
const userMenu = useDropdown();
const { users } = useUsers();
const query = useLibrarySearchStore(state => state.query);
const setQuery = useLibrarySearchStore(state => state.setQuery);
const path = useLibrarySearchStore(state => state.path);
const setPath = useLibrarySearchStore(state => state.setPath);
const head = useLibrarySearchStore(state => state.head);
const setHead = useLibrarySearchStore(state => state.setHead);
const folderMode = useLibrarySearchStore(state => state.folderMode);
const toggleFolderMode = useLibrarySearchStore(state => state.toggleFolderMode);
const isOwned = useLibrarySearchStore(state => state.isOwned);
const toggleOwned = useLibrarySearchStore(state => state.toggleOwned);
const isEditor = useLibrarySearchStore(state => state.isEditor);
const toggleEditor = useLibrarySearchStore(state => state.toggleEditor);
const isVisible = useLibrarySearchStore(state => state.isVisible);
const toggleVisible = useLibrarySearchStore(state => state.toggleVisible);
const filterUser = useLibrarySearchStore(state => state.filterUser);
const setFilterUser = useLibrarySearchStore(state => state.setFilterUser);
const resetFilter = useLibrarySearchStore(state => state.resetFilter);
const hasCustomFilter = useHasCustomFilter();
const userActive = isOwned !== undefined || isEditor !== undefined || filterUser !== undefined;
function handleChange(newValue: LocationHead | undefined) {
headMenu.hide();
onChangeHead(newValue);
setHead(newValue);
}
function handleToggleFolder() {
@ -155,9 +126,8 @@ function ToolbarSearch({
noBorder
placeholder='Выберите владельца'
className='min-w-[15rem] text-sm mx-1 mb-1'
items={users}
value={filterUser}
onSelectValue={onChangeFilterUser}
onSelectValue={setFilterUser}
/>
</Dropdown>
</div>
@ -177,7 +147,7 @@ function ToolbarSearch({
noBorder
className={clsx('min-w-[7rem] sm:min-w-[10rem] max-w-[20rem]', folderMode && 'flex-grow')}
query={query}
onChangeQuery={onChangeQuery}
onChangeQuery={setQuery}
/>
{!folderMode ? (
<div ref={headMenu.ref} className='flex items-center h-full py-1 select-none'>
@ -236,7 +206,7 @@ function ToolbarSearch({
noBorder
className='w-[4.5rem] sm:w-[5rem] flex-grow'
query={path}
onChangeQuery={onChangePath}
onChangeQuery={setPath}
/>
) : null}
</div>

View File

@ -1,62 +1,52 @@
import clsx from 'clsx';
import { toast } from 'react-toastify';
import { useAuth } from '@/backend/auth/useAuth';
import { SubfoldersIcon } from '@/components/DomainIcons';
import { IconFolderEdit, IconFolderTree } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp';
import { CProps } from '@/components/props';
import SelectLocation from '@/components/select/SelectLocation';
import MiniButton from '@/components/ui/MiniButton';
import { useAuth } from '@/context/AuthContext';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useLibrary } from '@/context/LibraryContext';
import useWindowSize from '@/hooks/useWindowSize';
import { FolderNode, FolderTree } from '@/models/FolderTree';
import { HelpTopic } from '@/models/miscellaneous';
import { useFitHeight } from '@/stores/appLayout';
import { useLibrarySearchStore } from '@/stores/librarySearch';
import { PARAMETER, prefixes } from '@/utils/constants';
import { information } from '@/utils/labels';
interface ViewSideLocationProps {
folderTree: FolderTree;
isVisible: boolean;
subfolders: boolean;
activeLocation: string;
onChangeActiveLocation: (newValue: string) => void;
toggleFolderMode: () => void;
toggleSubfolders: () => void;
onRenameLocation: () => void;
}
function ViewSideLocation({
folderTree,
activeLocation,
subfolders,
isVisible,
onChangeActiveLocation,
toggleFolderMode,
toggleSubfolders,
onRenameLocation
}: ViewSideLocationProps) {
function ViewSideLocation({ folderTree, isVisible, onRenameLocation }: ViewSideLocationProps) {
const { user } = useAuth();
const { items } = useLibrary();
const { calculateHeight } = useConceptOptions();
const windowSize = useWindowSize();
const location = useLibrarySearchStore(state => state.location);
const setLocation = useLibrarySearchStore(state => state.setLocation);
const toggleFolderMode = useLibrarySearchStore(state => state.toggleFolderMode);
const subfolders = useLibrarySearchStore(state => state.subfolders);
const toggleSubfolders = useLibrarySearchStore(state => state.toggleSubfolders);
const canRename = (() => {
if (activeLocation.length <= 3 || !user) {
if (location.length <= 3 || !user) {
return false;
}
if (user.is_staff) {
return true;
}
const owned = items.filter(item => item.owner == user.id);
const located = owned.filter(
item => item.location == activeLocation || item.location.startsWith(`${activeLocation}/`)
);
const located = owned.filter(item => item.location == location || item.location.startsWith(`${location}/`));
return located.length !== 0;
})();
const maxHeight = calculateHeight('4.5rem');
const maxHeight = useFitHeight('4.5rem');
function handleClickFolder(event: CProps.EventMouse, target: FolderNode) {
event.preventDefault();
@ -67,7 +57,7 @@ function ViewSideLocation({
.then(() => toast.success(information.pathReady))
.catch(console.error);
} else {
onChangeActiveLocation(target.getPath());
setLocation(target.getPath());
}
}
@ -98,7 +88,7 @@ function ViewSideLocation({
onClick={onRenameLocation}
/>
) : null}
{!!activeLocation ? (
{!!location ? (
<MiniButton
title={subfolders ? 'Вложенные папки: Вкл' : 'Вложенные папки: Выкл'} // prettier: split-lines
icon={<SubfoldersIcon value={subfolders} />}
@ -113,7 +103,7 @@ function ViewSideLocation({
</div>
</div>
<SelectLocation
value={activeLocation}
value={location}
folderTree={folderTree}
prefix={prefixes.folders_list}
onClick={handleClickFolder}

View File

@ -5,15 +5,15 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin';
import InfoError, { ErrorData } from '@/components/info/InfoError';
import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput';
import TextURL from '@/components/ui/TextURL';
import ExpectedAnonymous from '@/components/wrap/ExpectedAnonymous';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import useQueryStrings from '@/hooks/useQueryStrings';
import { IUserLoginData } from '@/models/user';
import { resources } from '@/utils/constants';
function LoginPage() {
@ -21,23 +21,20 @@ function LoginPage() {
const query = useQueryStrings();
const userQuery = query.get('username');
const { user, login, loading, error, setError } = useAuth();
const { user } = useAuth();
const { login, isPending, error, reset } = useLogin();
const [username, setUsername] = useState(userQuery || '');
const [password, setPassword] = useState('');
useEffect(() => {
setError(undefined);
}, [username, password, setError]);
reset();
}, [username, password, reset]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!loading) {
const data: IUserLoginData = {
username: username,
password: password
};
login(data, () => {
if (!isPending) {
login(username, password, () => {
if (router.canBack()) {
router.back();
} else {
@ -78,7 +75,7 @@ function LoginPage() {
<SubmitButton
text='Войти'
className='self-center w-[12rem] mt-3'
loading={loading}
loading={isPending}
disabled={!username || !password}
/>
<div className='flex flex-col text-sm'>

View File

@ -3,10 +3,10 @@
import { useCallback } from 'react';
import { urls } from '@/app/urls';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useConceptNavigation } from '@/context/NavigationContext';
import useQueryStrings from '@/hooks/useQueryStrings';
import { HelpTopic } from '@/models/miscellaneous';
import { useMainHeight } from '@/stores/appLayout';
import { PARAMETER } from '@/utils/constants';
import TopicsList from './TopicsList';
@ -17,7 +17,7 @@ function ManualsPage() {
const query = useQueryStrings();
const activeTopic = (query.get('topic') || HelpTopic.MAIN) as HelpTopic;
const { mainHeight } = useConceptOptions();
const mainHeight = useMainHeight();
const onSelectTopic = useCallback(
(newTopic: HelpTopic) => {

View File

@ -6,9 +6,9 @@ import { useCallback } from 'react';
import { IconMenuFold, IconMenuUnfold } from '@/components/Icons';
import Button from '@/components/ui/Button';
import SelectTree from '@/components/ui/SelectTree';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import useDropdown from '@/hooks/useDropdown';
import { HelpTopic, topicParent } from '@/models/miscellaneous';
import { useAppLayoutStore, useFitHeight } from '@/stores/appLayout';
import { PARAMETER, prefixes } from '@/utils/constants';
import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
@ -19,7 +19,8 @@ interface TopicsDropdownProps {
function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
const menu = useDropdown();
const { noNavigation, calculateHeight } = useConceptOptions();
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const treeHeight = useFitHeight('4rem + 2px');
const handleSelectTopic = useCallback(
(topic: HelpTopic) => {
@ -67,7 +68,7 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
'bg-prim-200'
)}
style={{
maxHeight: calculateHeight('4rem + 2px'),
maxHeight: treeHeight,
transitionProperty: 'clip-path',
transitionDuration: `${PARAMETER.moveDuration}ms`,
clipPath: menu.isOpen ? 'inset(0% 0% 0% 0%)' : 'inset(0% 100% 0% 0%)'

View File

@ -1,8 +1,8 @@
import clsx from 'clsx';
import SelectTree from '@/components/ui/SelectTree';
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { HelpTopic, topicParent } from '@/models/miscellaneous';
import { useFitHeight } from '@/stores/appLayout';
import { prefixes } from '@/utils/constants';
import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
@ -12,7 +12,7 @@ interface TopicsStaticProps {
}
function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
const { calculateHeight } = useConceptOptions();
const topicsHeight = useFitHeight('1rem + 2px');
return (
<SelectTree
items={Object.values(HelpTopic).map(item => item as HelpTopic)}
@ -31,7 +31,7 @@ function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
'text-xs sm:text-sm bg-prim-200',
'select-none'
)}
style={{ maxHeight: calculateHeight('1rem + 2px') }}
style={{ maxHeight: topicsHeight }}
/>
);
}

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