Compare commits

...

16 Commits

Author SHA1 Message Date
Ivan
7792a82bf7 B: Fix contextmenu animations
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
2025-02-05 12:02:54 +03:00
Ivan
751d73a880 R: Remove redundant useCallback 2025-02-05 01:27:32 +03:00
Ivan
29e49997c3 B: Add timeout for cache invalidation after schema delete 2025-02-05 00:27:32 +03:00
Ivan
336794ec6c F: Rework createLibraryItem form 2025-02-04 23:33:35 +03:00
Ivan
e0abbe6534 R: Unify components API for user inputs 2025-02-04 20:35:18 +03:00
Ivan
4cf24d0200 F: Rework user profile editor 2025-02-03 19:56:03 +03:00
Ivan
f5419472f5 F: Rework password change 2025-02-03 18:17:07 +03:00
Ivan
9aa23aedfb F: Rework signup form using react-hook-form and zod 2025-02-03 13:13:11 +03:00
Ivan
18979dbaa3 F: Implement login form using react-hook-form 2025-01-31 21:04:21 +03:00
Ivan
e1cc428459 F: Use zod validation for login form 2025-01-30 21:00:59 +03:00
Ivan
178d6c3ba7 B: Fix register page 2025-01-30 20:39:16 +03:00
Ivan
e68906f2f0 M: Use zod coerce for type conversion 2025-01-30 20:08:15 +03:00
Ivan
f8758234f7 R: Refactor model types. Separate transport and model 2025-01-30 19:55:24 +03:00
Ivan
d81e015be1 F: Add zod validation for query params 2025-01-30 19:22:49 +03:00
Ivan
d139c07b7a F: Implement ErrorFallback for root 2025-01-30 12:55:51 +03:00
Ivan
532bf24df6 F: Create intermediate directories for backup 2025-01-30 12:12:44 +03:00
125 changed files with 1126 additions and 974 deletions

View File

@ -39,12 +39,15 @@ This readme file is used mostly to document project dependencies and conventions
- react-error-boundary - react-error-boundary
- react-tooltip - react-tooltip
- react-zoom-pan-pinch - react-zoom-pan-pinch
- react-hook-form
- reactflow - reactflow
- js-file-download - js-file-download
- use-debounce - use-debounce
- qrcode.react - qrcode.react
- html-to-image - html-to-image
- zustand - zustand
- zod
- @hookform/resolvers
- @tanstack/react-table - @tanstack/react-table
- @tanstack/react-query - @tanstack/react-query
- @tanstack/react-query-devtools - @tanstack/react-query-devtools

View File

@ -103,6 +103,7 @@ class UserSerializer(serializers.ModelSerializer):
'first_name', 'first_name',
'last_name', 'last_name',
] ]
read_only_fields = ('id', 'username')
def validate(self, attrs): def validate(self, attrs):
attrs = super().validate(attrs) attrs = super().validate(attrs)

View File

@ -101,6 +101,10 @@ class TestUserUserProfileAPIView(EndpointTester):
data = {'email': self.user2.email} data = {'email': self.user2.email}
self.executeBadData(data=data) self.executeBadData(data=data)
data = {'username': 'new_username'}
response = self.executeOK(data=data)
self.assertNotEqual(response.data['username'], data['username'])
self.logout() self.logout()
self.executeForbidden() self.executeForbidden()

View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^3.10.0",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.2", "@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2", "@tanstack/react-query-devtools": "^5.64.2",
@ -23,6 +24,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-intl": "^7.1.5", "react-intl": "^7.1.5",
"react-router": "^7.1.3", "react-router": "^7.1.3",
@ -33,6 +35,7 @@
"react-zoom-pan-pinch": "^3.6.1", "react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
@ -1698,6 +1701,15 @@
"tslib": "2" "tslib": "2"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -9101,6 +9113,22 @@
"react": ">=16.13.1" "react": ">=16.13.1"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.54.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
@ -10932,7 +10960,6 @@
"version": "3.24.1", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^3.10.0",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.2", "@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2", "@tanstack/react-query-devtools": "^5.64.2",
@ -27,6 +28,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-intl": "^7.1.5", "react-intl": "^7.1.5",
"react-router": "^7.1.3", "react-router": "^7.1.3",
@ -37,6 +39,7 @@
"react-zoom-pan-pinch": "^3.6.1", "react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4", "reactflow": "^11.11.4",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,5 +1,4 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import ConceptToaster from '@/app/ConceptToaster'; import ConceptToaster from '@/app/ConceptToaster';
@ -10,22 +9,10 @@ import ModalLoader from '@/components/ui/ModalLoader';
import { useAppLayoutStore, useMainHeight, useViewportHeight } from '@/stores/appLayout'; import { useAppLayoutStore, useMainHeight, useViewportHeight } from '@/stores/appLayout';
import { globals } from '@/utils/constants'; import { globals } from '@/utils/constants';
import ErrorFallback from './ErrorFallback';
import { GlobalDialogs } from './GlobalDialogs'; import { GlobalDialogs } from './GlobalDialogs';
import { GlobalTooltips } from './GlobalTooltips'; import { GlobalTooltips } from './GlobalTooltips';
import { NavigationState } from './Navigation/NavigationContext'; import { NavigationState } from './Navigation/NavigationContext';
const resetState = () => {
console.log('Resetting state after error fallback');
};
const logError = (error: Error, info: { componentStack?: string | null | undefined }) => {
console.log('Error fallback: ' + error.message);
if (info.componentStack) {
console.log('Component stack: ' + info.componentStack);
}
};
function ApplicationLayout() { function ApplicationLayout() {
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
const viewportHeight = useViewportHeight(); const viewportHeight = useViewportHeight();
@ -35,7 +22,6 @@ function ApplicationLayout() {
const noFooter = useAppLayoutStore(state => state.noFooter); const noFooter = useAppLayoutStore(state => state.noFooter);
return ( return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={logError} onReset={resetState}>
<NavigationState> <NavigationState>
<div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'> <div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'>
<ConceptToaster <ConceptToaster
@ -69,7 +55,6 @@ function ApplicationLayout() {
</div> </div>
</div> </div>
</NavigationState> </NavigationState>
</ErrorBoundary>
); );
} }

View File

@ -1,13 +1,19 @@
import { type FallbackProps } from 'react-error-boundary'; import { useNavigate, useRouteError } from 'react-router';
import InfoError from '@/components/info/InfoError'; import InfoError from '@/components/info/InfoError';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { function ErrorFallback() {
const error = useRouteError();
const router = useNavigate();
function resetErrorBoundary() {
Promise.resolve(router('/')).catch(console.log);
}
return ( return (
<div className='flex flex-col gap-3 my-3 items-center antialiased' role='alert'> <div className='flex flex-col gap-3 my-3 items-center antialiased' role='alert'>
<h1 className='my-2'>Что-то пошло не так!</h1> <h1 className='my-2'>Что-то пошло не так!</h1>
<Button onClick={resetErrorBoundary} text='Попробовать еще раз' /> <Button onClick={resetErrorBoundary} text='Вернуться на главную' />
<InfoError error={error as Error} /> <InfoError error={error as Error} />
</div> </div>
); );

View File

@ -13,13 +13,14 @@ import LoginPage from '@/pages/LoginPage';
import NotFoundPage from '@/pages/NotFoundPage'; import NotFoundPage from '@/pages/NotFoundPage';
import ApplicationLayout from './ApplicationLayout'; import ApplicationLayout from './ApplicationLayout';
import ErrorFallback from './ErrorFallback';
import { routes } from './urls'; import { routes } from './urls';
export const Router = createBrowserRouter([ export const Router = createBrowserRouter([
{ {
path: '/', path: '/',
element: <ApplicationLayout />, element: <ApplicationLayout />,
errorElement: <NotFoundPage />, errorElement: <ErrorFallback />,
loader: prefetchAuth, loader: prefetchAuth,
hydrateFallbackElement: <Loader />, hydrateFallbackElement: <Loader />,
children: [ children: [

View File

@ -24,18 +24,6 @@ export const routes = {
database_schema: 'database-schema' database_schema: 'database-schema'
}; };
interface SchemaProps {
id: number | string;
tab: number;
version?: number | string;
active?: number | string;
}
interface OssProps {
id: number | string;
tab: number;
}
/** /**
* Internal navigation URLs. * Internal navigation URLs.
*/ */
@ -58,12 +46,24 @@ export const urls = {
schema: (id: number | string, version?: number | string) => schema: (id: number | string, version?: number | string) =>
`/rsforms/${id}` + (version !== undefined ? `?v=${version}` : ''), `/rsforms/${id}` + (version !== undefined ? `?v=${version}` : ''),
oss: (id: number | string, tab?: number) => `/oss/${id}` + (tab !== undefined ? `?tab=${tab}` : ''), oss: (id: number | string, tab?: number) => `/oss/${id}` + (tab !== undefined ? `?tab=${tab}` : ''),
schema_props: ({ id, tab, version, active }: SchemaProps) => {
schema_props: ({
id,
tab,
version,
active
}: {
id: number | string;
tab: number;
version?: number | string;
active?: number | string;
}) => {
const versionStr = version !== undefined ? `v=${version}&` : ''; const versionStr = version !== undefined ? `v=${version}&` : '';
const activeStr = active !== undefined ? `&active=${active}` : ''; const activeStr = active !== undefined ? `&active=${active}` : '';
return `/rsforms/${id}?${versionStr}tab=${tab}${activeStr}`; return `/rsforms/${id}?${versionStr}tab=${tab}${activeStr}`;
}, },
oss_props: ({ id, tab }: OssProps) => {
oss_props: ({ id, tab }: { id: number | string; tab: number }) => {
return `/oss/${id}?tab=${tab}`; return `/oss/${id}?tab=${tab}`;
} }
}; };

View File

@ -53,8 +53,11 @@ export function axiosGet<ResponseData>({ endpoint, options }: IAxiosGetRequest)
.get<ResponseData>(endpoint, options) .get<ResponseData>(endpoint, options)
.then(response => response.data) .then(response => response.data)
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
if (error.name !== 'CanceledError') {
// Note: Ignore cancellation errors
toast.error(extractErrorMessage(error)); toast.error(extractErrorMessage(error));
console.error(error); console.error(error);
}
throw error; throw error;
}); });
} }

View File

@ -1,25 +1,46 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
import { axiosGet, axiosPost } from '@/backend/apiTransport'; import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { ICurrentUser } from '@/models/user'; import { ICurrentUser } from '@/models/user';
import { information } from '@/utils/labels'; import { errors, information } from '@/utils/labels';
/** /**
* Represents login data, used to authenticate users. * Represents login data, used to authenticate users.
*/ */
export interface IUserLoginDTO { export const UserLoginSchema = z.object({
username: string; username: z.string().nonempty(errors.requiredField),
password: string; password: z.string().nonempty(errors.requiredField)
} });
/**
* Represents login data, used to authenticate users.
*/
export type IUserLoginDTO = z.infer<typeof UserLoginSchema>;
/** /**
* Represents data needed to update password for current user. * Represents data needed to update password for current user.
*/ */
export interface IChangePasswordDTO { export const ChangePasswordSchema = z
old_password: string; .object({
new_password: string; old_password: z.string().nonempty(errors.requiredField),
} new_password: z.string().nonempty(errors.requiredField),
new_password2: z.string().nonempty(errors.requiredField)
})
.refine(schema => schema.new_password === schema.new_password2, {
path: ['new_password2'],
message: errors.passwordsMismatch
})
.refine(schema => schema.old_password !== schema.new_password, {
path: ['new_password'],
message: errors.passwordsSame
});
/**
* Represents data needed to update password for current user.
*/
export type IChangePasswordDTO = z.infer<typeof ChangePasswordSchema>;
/** /**
* Represents password reset request data. * Represents password reset request data.
@ -69,7 +90,7 @@ export const authApi = {
request: { data: data } request: { data: data }
}), }),
changePassword: (data: IChangePasswordDTO) => changePassword: (data: IChangePasswordDTO) =>
axiosPost({ axiosPatch({
endpoint: '/users/api/change-password', endpoint: '/users/api/change-password',
request: { request: {
data: data, data: data,

View File

@ -1,8 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { libraryApi } from '@/backend/library/api'; import { authApi, IUserLoginDTO } from './api';
import { authApi } from './api';
export const useLogin = () => { export const useLogin = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -10,14 +8,10 @@ export const useLogin = () => {
mutationKey: ['login'], mutationKey: ['login'],
mutationFn: authApi.login, mutationFn: authApi.login,
onSettled: () => client.invalidateQueries({ queryKey: [authApi.baseKey] }), onSettled: () => client.invalidateQueries({ queryKey: [authApi.baseKey] }),
onSuccess: () => client.removeQueries({ queryKey: [libraryApi.baseKey] }) onSuccess: () => client.resetQueries()
}); });
return { return {
login: ( login: (data: IUserLoginDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }),
username: string, //
password: string,
onSuccess?: () => void
) => mutation.mutate({ username, password }, { onSuccess }),
isPending: mutation.isPending, isPending: mutation.isPending,
error: mutation.error, error: mutation.error,
reset: mutation.reset reset: mutation.reset

View File

@ -7,8 +7,7 @@ export const useLogout = () => {
const mutation = useMutation({ const mutation = useMutation({
mutationKey: ['logout'], mutationKey: ['logout'],
mutationFn: authApi.logout, mutationFn: authApi.logout,
onSettled: () => client.invalidateQueries({ queryKey: [authApi.baseKey] }), onSuccess: () => client.resetQueries()
onSuccess: () => client.removeQueries()
}); });
return { logout: (onSuccess?: () => void) => mutation.mutate(undefined, { onSuccess }) }; return { logout: (onSuccess?: () => void) => mutation.mutate(undefined, { onSuccess }) };
}; };

View File

@ -21,6 +21,7 @@ export const useResetPassword = () => {
onSuccess?: () => void onSuccess?: () => void
) => resetMutation.mutate(data, { onSuccess }), ) => resetMutation.mutate(data, { onSuccess }),
isPending: resetMutation.isPending || validateMutation.isPending, isPending: resetMutation.isPending || validateMutation.isPending,
error: resetMutation.error ?? validateMutation.error error: resetMutation.error ?? validateMutation.error,
reset: resetMutation.reset
}; };
}; };

View File

@ -1,5 +1,4 @@
import { axiosPost } from '@/backend/apiTransport'; import { axiosPost } from '@/backend/apiTransport';
import { ILexemeData, IWordFormPlain } from '@/models/language';
/** /**
* Represents API result for text output. * Represents API result for text output.
@ -8,11 +7,26 @@ export interface ITextResult {
result: string; result: string;
} }
/**
* Represents wordform data used for backend communication.
*/
export interface IWordFormDTO {
text: string;
grams: string;
}
/**
* Represents lexeme response containing multiple {@link Wordform}s.
*/
export interface ILexemeResponse {
items: IWordFormDTO[];
}
export const cctextApi = { export const cctextApi = {
baseKey: 'cctext', baseKey: 'cctext',
inflectText: (data: IWordFormPlain) => inflectText: (data: IWordFormDTO) =>
axiosPost<IWordFormPlain, ITextResult>({ axiosPost<IWordFormDTO, ITextResult>({
endpoint: '/api/cctext/inflect', endpoint: '/api/cctext/inflect',
request: { data: data } request: { data: data }
}), }),
@ -22,7 +36,7 @@ export const cctextApi = {
request: { data: data } request: { data: data }
}), }),
generateLexeme: (data: { text: string }) => generateLexeme: (data: { text: string }) =>
axiosPost<{ text: string }, ILexemeData>({ axiosPost<{ text: string }, ILexemeResponse>({
endpoint: '/api/cctext/generate-lexeme', endpoint: '/api/cctext/generate-lexeme',
request: { data: data } request: { data: data }
}) })

View File

@ -1,9 +1,8 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { ILexemeData } from '@/models/language';
import { cctextApi } from './api'; import { cctextApi, ILexemeResponse } from './api';
export const useGenerateLexeme = () => { export const useGenerateLexeme = () => {
const mutation = useMutation({ const mutation = useMutation({
@ -13,7 +12,7 @@ export const useGenerateLexeme = () => {
return { return {
generateLexeme: ( generateLexeme: (
data: { text: string }, // data: { text: string }, //
onSuccess?: DataCallback<ILexemeData> onSuccess?: DataCallback<ILexemeResponse>
) => mutation.mutate(data, { onSuccess }) ) => mutation.mutate(data, { onSuccess })
}; };
}; };

View File

@ -1,9 +1,8 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { IWordFormPlain } from '@/models/language';
import { cctextApi, ITextResult } from './api'; import { cctextApi, ITextResult, IWordFormDTO } from './api';
export const useInflectText = () => { export const useInflectText = () => {
const mutation = useMutation({ const mutation = useMutation({
@ -12,7 +11,7 @@ export const useInflectText = () => {
}); });
return { return {
inflectText: ( inflectText: (
data: IWordFormPlain, // data: IWordFormDTO, //
onSuccess?: DataCallback<ITextResult> onSuccess?: DataCallback<ITextResult>
) => mutation.mutate(data, { onSuccess }) ) => mutation.mutate(data, { onSuccess })
}; };

View File

@ -1,9 +1,10 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport'; import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { ossApi } from '@/backend/oss/api'; import { ossApi } from '@/backend/oss/api';
import { rsformsApi } from '@/backend/rsform/api'; import { IRSFormDTO, rsformsApi } from '@/backend/rsform/api';
import { import {
AccessPolicy, AccessPolicy,
ILibraryItem, ILibraryItem,
@ -13,9 +14,10 @@ import {
LibraryItemType, LibraryItemType,
VersionID VersionID
} from '@/models/library'; } from '@/models/library';
import { ConstituentaID, IRSFormData } from '@/models/rsform'; import { validateLocation } from '@/models/libraryAPI';
import { ConstituentaID } from '@/models/rsform';
import { UserID } from '@/models/user'; import { UserID } from '@/models/user';
import { information } from '@/utils/labels'; import { errors, information } from '@/utils/labels';
/** /**
* Represents update data for renaming Location. * Represents update data for renaming Location.
@ -28,22 +30,49 @@ export interface IRenameLocationDTO {
/** /**
* Represents data, used for cloning {@link IRSForm}. * Represents data, used for cloning {@link IRSForm}.
*/ */
export interface IRSFormCloneDTO extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'owner'> { export interface IRCloneLibraryItemDTO extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'owner'> {
items?: ConstituentaID[]; items?: ConstituentaID[];
} }
/** /**
* Represents data, used for creating {@link IRSForm}. * Represents data, used for creating {@link IRSForm}.
*/ */
export interface ILibraryCreateDTO extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'id' | 'owner'> { export const CreateLibraryItemSchema = z
file?: File; .object({
fileName?: string; item_type: z.nativeEnum(LibraryItemType),
} title: z.string().optional(),
alias: z.string().optional(),
comment: z.string(),
visible: z.boolean(),
read_only: z.boolean(),
location: z.string(),
access_policy: z.nativeEnum(AccessPolicy),
file: z.instanceof(File).optional(),
fileName: z.string().optional()
})
.refine(data => validateLocation(data.location), {
path: ['location'],
message: errors.invalidLocation
})
.refine(data => !!data.file || !!data.title, {
path: ['title'],
message: errors.requiredField
})
.refine(data => !!data.file || !!data.alias, {
path: ['alias'],
message: errors.requiredField
});
/**
* Represents data, used for creating {@link IRSForm}.
*/
export type ICreateLibraryItemDTO = z.infer<typeof CreateLibraryItemSchema>;
/** /**
* Represents update data for editing {@link ILibraryItem}. * Represents update data for editing {@link ILibraryItem}.
*/ */
export interface ILibraryUpdateDTO export interface IUpdateLibraryItemDTO
extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'access_policy' | 'location' | 'owner'> {} extends Omit<ILibraryItem, 'time_create' | 'time_update' | 'access_policy' | 'location' | 'owner'> {}
/** /**
@ -60,7 +89,7 @@ export interface IVersionCreateDTO {
*/ */
export interface IVersionCreatedResponse { export interface IVersionCreatedResponse {
version: number; version: number;
schema: IRSFormData; schema: IRSFormDTO;
} }
export const libraryApi = { export const libraryApi = {
@ -93,8 +122,8 @@ export const libraryApi = {
}) })
}), }),
createItem: (data: ILibraryCreateDTO) => createItem: (data: ICreateLibraryItemDTO) =>
axiosPost<ILibraryCreateDTO, ILibraryItem>({ axiosPost<ICreateLibraryItemDTO, ILibraryItem>({
endpoint: !data.file ? '/api/library' : '/api/rsforms/create-detailed', endpoint: !data.file ? '/api/library' : '/api/rsforms/create-detailed',
request: { request: {
data: data, data: data,
@ -108,8 +137,8 @@ export const libraryApi = {
} }
} }
}), }),
updateItem: (data: ILibraryUpdateDTO) => updateItem: (data: IUpdateLibraryItemDTO) =>
axiosPatch<ILibraryUpdateDTO, ILibraryItem>({ axiosPatch<IUpdateLibraryItemDTO, ILibraryItem>({
endpoint: `/api/library/${data.id}`, endpoint: `/api/library/${data.id}`,
request: { request: {
data: data, data: data,
@ -156,8 +185,8 @@ export const libraryApi = {
successMessage: information.itemDestroyed successMessage: information.itemDestroyed
} }
}), }),
cloneItem: (data: IRSFormCloneDTO) => cloneItem: (data: IRCloneLibraryItemDTO) =>
axiosPost<IRSFormCloneDTO, IRSFormData>({ axiosPost<IRCloneLibraryItemDTO, IRSFormDTO>({
endpoint: `/api/library/${data.id}/clone`, endpoint: `/api/library/${data.id}/clone`,
request: { request: {
data: data, data: data,
@ -182,7 +211,7 @@ export const libraryApi = {
} }
}), }),
versionRestore: ({ versionID }: { versionID: VersionID }) => versionRestore: ({ versionID }: { versionID: VersionID }) =>
axiosPatch<undefined, IRSFormData>({ axiosPatch<undefined, IRSFormDTO>({
endpoint: `/api/versions/${versionID}/restore`, endpoint: `/api/versions/${versionID}/restore`,
request: { request: {
successMessage: information.versionRestored successMessage: information.versionRestored

View File

@ -1,9 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { IRSFormData } from '@/models/rsform';
import { IRSFormCloneDTO, libraryApi } from './api'; import { IRSFormDTO } from '../rsform/api';
import { IRCloneLibraryItemDTO, libraryApi } from './api';
export const useCloneItem = () => { export const useCloneItem = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -14,8 +14,8 @@ export const useCloneItem = () => {
}); });
return { return {
cloneItem: ( cloneItem: (
data: IRSFormCloneDTO, // data: IRCloneLibraryItemDTO, //
onSuccess?: DataCallback<IRSFormData> onSuccess?: DataCallback<IRSFormDTO>
) => mutation.mutate(data, { onSuccess }) ) => mutation.mutate(data, { onSuccess })
}; };
}; };

View File

@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { ILibraryItem } from '@/models/library'; import { ILibraryItem } from '@/models/library';
import { ILibraryCreateDTO, libraryApi } from './api'; import { ICreateLibraryItemDTO, libraryApi } from './api';
export const useCreateItem = () => { export const useCreateItem = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -14,7 +14,7 @@ export const useCreateItem = () => {
}); });
return { return {
createItem: ( createItem: (
data: ILibraryCreateDTO, // data: ICreateLibraryItemDTO, //
onSuccess?: DataCallback<ILibraryItem> onSuccess?: DataCallback<ILibraryItem>
) => mutation.mutate(data, { onSuccess }), ) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending, isPending: mutation.isPending,

View File

@ -2,7 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ossApi } from '@/backend/oss/api'; import { ossApi } from '@/backend/oss/api';
import { rsformsApi } from '@/backend/rsform/api'; import { rsformsApi } from '@/backend/rsform/api';
import { ILibraryItem, LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { PARAMETER } from '@/utils/constants';
import { libraryApi } from './api'; import { libraryApi } from './api';
@ -12,13 +13,16 @@ export const useDeleteItem = () => {
mutationKey: [libraryApi.baseKey, 'delete-item'], mutationKey: [libraryApi.baseKey, 'delete-item'],
mutationFn: libraryApi.deleteItem, mutationFn: libraryApi.deleteItem,
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) => client.invalidateQueries({ queryKey: libraryApi.libraryListKey }).catch(console.error);
prev?.filter(item => item.id !== variables) setTimeout(
); () =>
return Promise.allSettled([ void Promise.allSettled([
client.invalidateQueries({ queryKey: [ossApi.baseKey] }), client.invalidateQueries({ queryKey: [ossApi.baseKey] }),
client.invalidateQueries({ queryKey: rsformsApi.getRSFormQueryOptions({ itemID: variables }).queryKey }) client.resetQueries({ queryKey: rsformsApi.getRSFormQueryOptions({ itemID: variables }).queryKey }),
]); client.resetQueries({ queryKey: ossApi.getOssQueryOptions({ itemID: variables }).queryKey })
]).catch(console.error),
PARAMETER.navigationDuration
);
} }
}); });
return { return {

View File

@ -1,9 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ossApi } from '@/backend/oss/api'; import { IOperationSchemaDTO, ossApi } from '@/backend/oss/api';
import { rsformsApi } from '@/backend/rsform/api'; import { rsformsApi } from '@/backend/rsform/api';
import { AccessPolicy, ILibraryItem, LibraryItemID } from '@/models/library'; import { AccessPolicy, ILibraryItem, LibraryItemID } from '@/models/library';
import { IOperationSchemaData } from '@/models/oss';
import { libraryApi } from './api'; import { libraryApi } from './api';
@ -14,7 +13,7 @@ export const useSetAccessPolicy = () => {
mutationFn: libraryApi.setAccessPolicy, mutationFn: libraryApi.setAccessPolicy,
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
const ossKey = ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey; const ossKey = ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey;
const ossData: IOperationSchemaData | undefined = client.getQueryData(ossKey); const ossData: IOperationSchemaDTO | undefined = client.getQueryData(ossKey);
if (ossData) { if (ossData) {
client.setQueryData(ossKey, { ...ossData, access_policy: variables.policy }); client.setQueryData(ossKey, { ...ossData, access_policy: variables.policy });
return Promise.allSettled([ return Promise.allSettled([

View File

@ -1,9 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ossApi } from '@/backend/oss/api'; import { IOperationSchemaDTO, ossApi } from '@/backend/oss/api';
import { rsformsApi } from '@/backend/rsform/api'; import { rsformsApi } from '@/backend/rsform/api';
import { ILibraryItem, LibraryItemID } from '@/models/library'; import { ILibraryItem, LibraryItemID } from '@/models/library';
import { IOperationSchemaData } from '@/models/oss';
import { libraryApi } from './api'; import { libraryApi } from './api';
@ -14,7 +13,7 @@ export const useSetLocation = () => {
mutationFn: libraryApi.setLocation, mutationFn: libraryApi.setLocation,
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
const ossKey = ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey; const ossKey = ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey;
const ossData: IOperationSchemaData | undefined = client.getQueryData(ossKey); const ossData: IOperationSchemaDTO | undefined = client.getQueryData(ossKey);
if (ossData) { if (ossData) {
client.setQueryData(ossKey, { ...ossData, location: variables.location }); client.setQueryData(ossKey, { ...ossData, location: variables.location });
return Promise.allSettled([ return Promise.allSettled([

View File

@ -1,9 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ossApi } from '@/backend/oss/api'; import { IOperationSchemaDTO, ossApi } from '@/backend/oss/api';
import { rsformsApi } from '@/backend/rsform/api'; import { rsformsApi } from '@/backend/rsform/api';
import { ILibraryItem, LibraryItemID } from '@/models/library'; import { ILibraryItem, LibraryItemID } from '@/models/library';
import { IOperationSchemaData } from '@/models/oss';
import { UserID } from '@/models/user'; import { UserID } from '@/models/user';
import { libraryApi } from './api'; import { libraryApi } from './api';
@ -15,7 +14,7 @@ export const useSetOwner = () => {
mutationFn: libraryApi.setOwner, mutationFn: libraryApi.setOwner,
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
const ossKey = ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey; const ossKey = ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey;
const ossData: IOperationSchemaData | undefined = client.getQueryData(ossKey); const ossData: IOperationSchemaDTO | undefined = client.getQueryData(ossKey);
if (ossData) { if (ossData) {
client.setQueryData(ossKey, { ...ossData, owner: variables.owner }); client.setQueryData(ossKey, { ...ossData, owner: variables.owner });
return Promise.allSettled([ return Promise.allSettled([

View File

@ -1,11 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ossApi } from '@/backend/oss/api'; import { IOperationSchemaDTO, ossApi } from '@/backend/oss/api';
import { ILibraryItem, LibraryItemType } from '@/models/library'; import { ILibraryItem, LibraryItemType } from '@/models/library';
import { IOperationSchemaData } from '@/models/oss';
import { IRSFormData } from '@/models/rsform';
import { ILibraryUpdateDTO, libraryApi } from './api'; import { IRSFormDTO } from '../rsform/api';
import { IUpdateLibraryItemDTO, libraryApi } from './api';
export const useUpdateItem = () => { export const useUpdateItem = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -17,11 +16,11 @@ export const useUpdateItem = () => {
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) => client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
prev?.map(item => (item.id === data.id ? data : item)) prev?.map(item => (item.id === data.id ? data : item))
); );
client.setQueryData(itemKey, (prev: IRSFormData | IOperationSchemaData | undefined) => client.setQueryData(itemKey, (prev: IRSFormDTO | IOperationSchemaDTO | undefined) =>
!prev ? undefined : { ...prev, ...data } !prev ? undefined : { ...prev, ...data }
); );
if (data.item_type === LibraryItemType.RSFORM) { if (data.item_type === LibraryItemType.RSFORM) {
const schema: IRSFormData | undefined = client.getQueryData(itemKey); const schema: IRSFormDTO | undefined = client.getQueryData(itemKey);
if (schema) { if (schema) {
return Promise.allSettled( return Promise.allSettled(
schema.oss.map(item => schema.oss.map(item =>
@ -33,6 +32,6 @@ export const useUpdateItem = () => {
} }
}); });
return { return {
updateItem: (data: ILibraryUpdateDTO) => mutation.mutate(data) updateItem: (data: IUpdateLibraryItemDTO) => mutation.mutate(data)
}; };
}; };

View File

@ -1,8 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { rsformsApi } from '@/backend/rsform/api'; import { IRSFormDTO, rsformsApi } from '@/backend/rsform/api';
import { LibraryItemID, VersionID } from '@/models/library'; import { LibraryItemID, VersionID } from '@/models/library';
import { IRSFormData } from '@/models/rsform';
import { libraryApi } from './api'; import { libraryApi } from './api';
@ -14,7 +13,7 @@ export const useVersionDelete = () => {
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
client.setQueryData( client.setQueryData(
rsformsApi.getRSFormQueryOptions({ itemID: variables.itemID }).queryKey, rsformsApi.getRSFormQueryOptions({ itemID: variables.itemID }).queryKey,
(prev: IRSFormData | undefined) => (prev: IRSFormDTO | undefined) =>
!prev !prev
? undefined ? undefined
: { : {

View File

@ -1,8 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { rsformsApi } from '@/backend/rsform/api'; import { IRSFormDTO, rsformsApi } from '@/backend/rsform/api';
import { IVersionData, VersionID } from '@/models/library'; import { IVersionData, VersionID } from '@/models/library';
import { IRSFormData } from '@/models/rsform';
import { libraryApi } from './api'; import { libraryApi } from './api';
@ -14,7 +13,7 @@ export const useVersionUpdate = () => {
onSuccess: data => { onSuccess: data => {
client.setQueryData( client.setQueryData(
rsformsApi.getRSFormQueryOptions({ itemID: data.item }).queryKey, rsformsApi.getRSFormQueryOptions({ itemID: data.item }).queryKey,
(prev: IRSFormData | undefined) => (prev: IRSFormDTO | undefined) =>
!prev !prev
? undefined ? undefined
: { : {

View File

@ -2,29 +2,24 @@
* Module: OSS data loading and processing. * Module: OSS data loading and processing.
*/ */
import { Graph } from './Graph'; import { Graph } from '@/models/Graph';
import { ILibraryItem, LibraryItemID } from './library'; import { ILibraryItem, LibraryItemID } from '@/models/library';
import { import { IOperation, IOperationSchema, IOperationSchemaStats, OperationID, OperationType } from '@/models/oss';
IOperation,
IOperationSchema, import { IOperationSchemaDTO } from './api';
IOperationSchemaData,
IOperationSchemaStats,
OperationID,
OperationType
} from './oss';
/** /**
* Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaData}. * Loads data into an {@link IOperationSchema} based on {@link IOperationSchemaDTO}.
* *
*/ */
export class OssLoader { export class OssLoader {
private oss: IOperationSchemaData; private oss: IOperationSchemaDTO;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private operationByID = new Map<OperationID, IOperation>(); private operationByID = new Map<OperationID, IOperation>();
private schemaIDs: LibraryItemID[] = []; private schemaIDs: LibraryItemID[] = [];
private items: ILibraryItem[]; private items: ILibraryItem[];
constructor(input: IOperationSchemaData, items: ILibraryItem[]) { constructor(input: IOperationSchemaDTO, items: ILibraryItem[]) {
this.oss = input; this.oss = input;
this.items = items; this.items = items;
} }

View File

@ -2,18 +2,33 @@ import { queryOptions } from '@tanstack/react-query';
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport'; import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { ILibraryItem, LibraryItemID } from '@/models/library'; import { ILibraryItem, ILibraryItemData, LibraryItemID } from '@/models/library';
import { import {
IArgument,
ICstSubstitute, ICstSubstitute,
IOperationData, ICstSubstituteEx,
IOperation,
IOperationPosition, IOperationPosition,
IOperationSchemaData,
OperationID, OperationID,
OperationType OperationType
} from '@/models/oss'; } from '@/models/oss';
import { ConstituentaID, IConstituentaReference, ITargetCst } from '@/models/rsform'; import { ConstituentaID, IConstituentaReference, ITargetCst } from '@/models/rsform';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
/**
* Represents {@link IOperation} data from server.
*/
export interface IOperationDTO extends Omit<IOperation, 'substitutions' | 'arguments'> {}
/**
* Represents backend data for {@link IOperationSchema}.
*/
export interface IOperationSchemaDTO extends ILibraryItemData {
items: IOperationDTO[];
arguments: IArgument[];
substitutions: ICstSubstituteEx[];
}
/** /**
* Represents {@link IOperation} data, used in creation process. * Represents {@link IOperation} data, used in creation process.
*/ */
@ -36,8 +51,8 @@ export interface IOperationCreateDTO {
* Represents data response when creating {@link IOperation}. * Represents data response when creating {@link IOperation}.
*/ */
export interface IOperationCreatedResponse { export interface IOperationCreatedResponse {
new_operation: IOperationData; new_operation: IOperationDTO;
oss: IOperationSchemaData; oss: IOperationSchemaDTO;
} }
/** /**
@ -61,7 +76,7 @@ export interface IOperationDeleteDTO extends ITargetOperation {
*/ */
export interface IInputCreatedResponse { export interface IInputCreatedResponse {
new_schema: ILibraryItem; new_schema: ILibraryItem;
oss: IOperationSchemaData; oss: IOperationSchemaDTO;
} }
/** /**
@ -102,7 +117,7 @@ export const ossApi = {
queryFn: meta => queryFn: meta =>
!itemID !itemID
? undefined ? undefined
: axiosGet<IOperationSchemaData>({ : axiosGet<IOperationSchemaDTO>({
endpoint: `/api/oss/${itemID}/details`, endpoint: `/api/oss/${itemID}/details`,
options: { signal: meta.signal } options: { signal: meta.signal }
}) })
@ -135,7 +150,7 @@ export const ossApi = {
} }
}), }),
operationDelete: ({ itemID, data }: { itemID: LibraryItemID; data: IOperationDeleteDTO }) => operationDelete: ({ itemID, data }: { itemID: LibraryItemID; data: IOperationDeleteDTO }) =>
axiosDelete<IOperationDeleteDTO, IOperationSchemaData>({ axiosDelete<IOperationDeleteDTO, IOperationSchemaDTO>({
endpoint: `/api/oss/${itemID}/delete-operation`, endpoint: `/api/oss/${itemID}/delete-operation`,
request: { request: {
data: data, data: data,
@ -151,7 +166,7 @@ export const ossApi = {
} }
}), }),
inputUpdate: ({ itemID, data }: { itemID: LibraryItemID; data: IInputUpdateDTO }) => inputUpdate: ({ itemID, data }: { itemID: LibraryItemID; data: IInputUpdateDTO }) =>
axiosPatch<IInputUpdateDTO, IOperationSchemaData>({ axiosPatch<IInputUpdateDTO, IOperationSchemaDTO>({
endpoint: `/api/oss/${itemID}/set-input`, endpoint: `/api/oss/${itemID}/set-input`,
request: { request: {
data: data, data: data,
@ -159,7 +174,7 @@ export const ossApi = {
} }
}), }),
operationUpdate: ({ itemID, data }: { itemID: LibraryItemID; data: IOperationUpdateDTO }) => operationUpdate: ({ itemID, data }: { itemID: LibraryItemID; data: IOperationUpdateDTO }) =>
axiosPatch<IOperationUpdateDTO, IOperationSchemaData>({ axiosPatch<IOperationUpdateDTO, IOperationSchemaDTO>({
endpoint: `/api/oss/${itemID}/update-operation`, endpoint: `/api/oss/${itemID}/update-operation`,
request: { request: {
data: data, data: data,
@ -167,7 +182,7 @@ export const ossApi = {
} }
}), }),
operationExecute: ({ itemID, data }: { itemID: LibraryItemID; data: ITargetOperation }) => operationExecute: ({ itemID, data }: { itemID: LibraryItemID; data: ITargetOperation }) =>
axiosPost<ITargetOperation, IOperationSchemaData>({ axiosPost<ITargetOperation, IOperationSchemaDTO>({
endpoint: `/api/oss/${itemID}/execute-operation`, endpoint: `/api/oss/${itemID}/execute-operation`,
request: { request: {
data: data, data: data,
@ -176,7 +191,7 @@ export const ossApi = {
}), }),
relocateConstituents: ({ itemID, data }: { itemID: LibraryItemID; data: ICstRelocateDTO }) => relocateConstituents: ({ itemID, data }: { itemID: LibraryItemID; data: ICstRelocateDTO }) =>
axiosPost<ICstRelocateDTO, IOperationSchemaData>({ axiosPost<ICstRelocateDTO, IOperationSchemaDTO>({
endpoint: `/api/oss/${itemID}/relocate-constituents`, endpoint: `/api/oss/${itemID}/relocate-constituents`,
request: { request: {
data: data, data: data,

View File

@ -1,8 +1,8 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useLibrary, useLibrarySuspense } from '@/backend/library/useLibrary'; import { useLibrary, useLibrarySuspense } from '@/backend/library/useLibrary';
import { OssLoader } from '@/backend/oss/OssLoader';
import { LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { OssLoader } from '@/models/OssLoader';
import { queryClient } from '../queryClient'; import { queryClient } from '../queryClient';
import { ossApi } from './api'; import { ossApi } from './api';

View File

@ -3,9 +3,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { useUpdateTimestamp } from '@/backend/library/useUpdateTimestamp'; import { useUpdateTimestamp } from '@/backend/library/useUpdateTimestamp';
import { LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { IOperationData } from '@/models/oss';
import { IOperationCreateDTO, ossApi } from './api'; import { IOperationCreateDTO, IOperationDTO, ossApi } from './api';
export const useOperationCreate = () => { export const useOperationCreate = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -24,7 +23,7 @@ export const useOperationCreate = () => {
itemID: LibraryItemID; // itemID: LibraryItemID; //
data: IOperationCreateDTO; data: IOperationCreateDTO;
}, },
onSuccess?: DataCallback<IOperationData> onSuccess?: DataCallback<IOperationDTO>
) => mutation.mutate(data, { onSuccess: response => onSuccess?.(response.new_operation) }) ) => mutation.mutate(data, { onSuccess: response => onSuccess?.(response.new_operation) })
}; };
}; };

View File

@ -2,27 +2,29 @@
* Module: RSForm data loading and processing. * Module: RSForm data loading and processing.
*/ */
import { Graph } from './Graph'; import { Graph } from '@/models/Graph';
import { LibraryItemID } from './library'; import { LibraryItemID } from '@/models/library';
import { ConstituentaID, CstType, IConstituenta, IRSForm, IRSFormData, IRSFormStats } from './rsform'; import { ConstituentaID, CstType, IConstituenta, IRSForm, IRSFormStats } from '@/models/rsform';
import { inferClass, inferStatus, inferTemplate, isBaseSet, isFunctional } from './rsformAPI'; import { inferClass, inferStatus, inferTemplate, isBaseSet, isFunctional } from '@/models/rsformAPI';
import { ParsingStatus, ValueClass } from './rslang'; import { ParsingStatus, ValueClass } from '@/models/rslang';
import { extractGlobals, isSimpleExpression, splitTemplateDefinition } from './rslangAPI'; import { extractGlobals, isSimpleExpression, splitTemplateDefinition } from '@/models/rslangAPI';
import { IRSFormDTO } from './api';
/** /**
* Loads data into an {@link IRSForm} based on {@link IRSFormData}. * Loads data into an {@link IRSForm} based on {@link IRSFormDTO}.
* *
* @remarks * @remarks
* This function processes the provided input, initializes the IRSForm, and calculates statistics * This function processes the provided input, initializes the IRSForm, and calculates statistics
* based on the loaded data. It also establishes dependencies between concepts in the graph. * based on the loaded data. It also establishes dependencies between concepts in the graph.
*/ */
export class RSFormLoader { export class RSFormLoader {
private schema: IRSFormData; private schema: IRSFormDTO;
private graph: Graph = new Graph(); private graph: Graph = new Graph();
private cstByAlias = new Map<string, IConstituenta>(); private cstByAlias = new Map<string, IConstituenta>();
private cstByID = new Map<ConstituentaID, IConstituenta>(); private cstByID = new Map<ConstituentaID, IConstituenta>();
constructor(input: IRSFormData) { constructor(input: IRSFormDTO) {
this.schema = input; this.schema = input;
} }
@ -159,7 +161,8 @@ export class RSFormLoader {
} else if (sources.size !== 1) { } else if (sources.size !== 1) {
return false; return false;
} else { } else {
const base = this.cstByID.get(sources.values().next().value!)!; const cstID = sources.values().next().value!;
const base = this.cstByID.get(cstID)!;
return !isFunctional(base.cst_type) || splitTemplateDefinition(base.definition_formal).head !== expression.head; return !isFunctional(base.cst_type) || splitTemplateDefinition(base.definition_formal).head !== expression.head;
} }
}; };

View File

@ -2,20 +2,42 @@ import { queryOptions } from '@tanstack/react-query';
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport'; import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { LibraryItemID, VersionID } from '@/models/library'; import { ILibraryItemReference, ILibraryItemVersioned, LibraryItemID, VersionID } from '@/models/library';
import { ICstSubstitute, ICstSubstitutions } from '@/models/oss'; import { ICstSubstitute, ICstSubstitutions } from '@/models/oss';
import { import {
ConstituentaID, ConstituentaID,
CstType, CstType,
IConstituentaList, IConstituentaList,
IConstituentaMeta, IConstituentaMeta,
IRSFormData, IInheritanceInfo,
ITargetCst, ITargetCst,
TermForm TermForm
} from '@/models/rsform'; } from '@/models/rsform';
import { IExpressionParse } from '@/models/rslang'; import { IArgumentInfo, IExpressionParse, ParsingStatus, ValueClass } from '@/models/rslang';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
/**
* Represents {@link IConstituenta} data from server.
*/
export interface IConstituentaDTO extends IConstituentaMeta {
parse: {
status: ParsingStatus;
valueClass: ValueClass;
typification: string;
syntaxTree: string;
args: IArgumentInfo[];
};
}
/**
* Represents data for {@link IRSForm} provided by backend.
*/
export interface IRSFormDTO extends ILibraryItemVersioned {
items: IConstituentaDTO[];
inheritance: IInheritanceInfo[];
oss: ILibraryItemReference[];
}
/** /**
* Represents data, used for uploading {@link IRSForm} as file. * Represents data, used for uploading {@link IRSForm} as file.
*/ */
@ -46,7 +68,7 @@ export interface ICstCreateDTO {
*/ */
export interface ICstCreatedResponse { export interface ICstCreatedResponse {
new_cst: IConstituentaMeta; new_cst: IConstituentaMeta;
schema: IRSFormData; schema: IRSFormDTO;
} }
/** /**
@ -85,7 +107,7 @@ export interface ICstMoveDTO {
*/ */
export interface IProduceStructureResponse { export interface IProduceStructureResponse {
cst_list: ConstituentaID[]; cst_list: ConstituentaID[];
schema: IRSFormData; schema: IRSFormDTO;
} }
/** /**
@ -117,7 +139,7 @@ export const rsformsApi = {
queryFn: meta => queryFn: meta =>
!itemID !itemID
? undefined ? undefined
: axiosGet<IRSFormData>({ : axiosGet<IRSFormDTO>({
endpoint: version ? `/api/library/${itemID}/versions/${version}` : `/api/rsforms/${itemID}/details`, endpoint: version ? `/api/library/${itemID}/versions/${version}` : `/api/rsforms/${itemID}/details`,
options: { signal: meta.signal } options: { signal: meta.signal }
}) })
@ -130,7 +152,7 @@ export const rsformsApi = {
options: { responseType: 'blob' } options: { responseType: 'blob' }
}), }),
upload: (data: IRSFormUploadDTO) => upload: (data: IRSFormUploadDTO) =>
axiosPatch<IRSFormUploadDTO, IRSFormData>({ axiosPatch<IRSFormUploadDTO, IRSFormDTO>({
endpoint: `/api/rsforms/${data.itemID}/load-trs`, endpoint: `/api/rsforms/${data.itemID}/load-trs`,
request: { request: {
data: data, data: data,
@ -160,7 +182,7 @@ export const rsformsApi = {
} }
}), }),
cstDelete: ({ itemID, data }: { itemID: LibraryItemID; data: IConstituentaList }) => cstDelete: ({ itemID, data }: { itemID: LibraryItemID; data: IConstituentaList }) =>
axiosDelete<IConstituentaList, IRSFormData>({ axiosDelete<IConstituentaList, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/delete-multiple-cst`, endpoint: `/api/rsforms/${itemID}/delete-multiple-cst`,
request: { request: {
data: data, data: data,
@ -176,7 +198,7 @@ export const rsformsApi = {
} }
}), }),
cstSubstitute: ({ itemID, data }: { itemID: LibraryItemID; data: ICstSubstitutions }) => cstSubstitute: ({ itemID, data }: { itemID: LibraryItemID; data: ICstSubstitutions }) =>
axiosPatch<ICstSubstitutions, IRSFormData>({ axiosPatch<ICstSubstitutions, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/substitute`, endpoint: `/api/rsforms/${itemID}/substitute`,
request: { request: {
data: data, data: data,
@ -184,7 +206,7 @@ export const rsformsApi = {
} }
}), }),
cstMove: ({ itemID, data }: { itemID: LibraryItemID; data: ICstMoveDTO }) => cstMove: ({ itemID, data }: { itemID: LibraryItemID; data: ICstMoveDTO }) =>
axiosPatch<ICstMoveDTO, IRSFormData>({ axiosPatch<ICstMoveDTO, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/move-cst`, endpoint: `/api/rsforms/${itemID}/move-cst`,
request: { data: data } request: { data: data }
}), }),
@ -198,7 +220,7 @@ export const rsformsApi = {
} }
}), }),
inlineSynthesis: ({ itemID, data }: { itemID: LibraryItemID; data: IInlineSynthesisDTO }) => inlineSynthesis: ({ itemID, data }: { itemID: LibraryItemID; data: IInlineSynthesisDTO }) =>
axiosPost<IInlineSynthesisDTO, IRSFormData>({ axiosPost<IInlineSynthesisDTO, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/inline-synthesis`, endpoint: `/api/rsforms/${itemID}/inline-synthesis`,
request: { request: {
data: data, data: data,
@ -206,12 +228,12 @@ export const rsformsApi = {
} }
}), }),
restoreOrder: ({ itemID }: { itemID: LibraryItemID }) => restoreOrder: ({ itemID }: { itemID: LibraryItemID }) =>
axiosPatch<undefined, IRSFormData>({ axiosPatch<undefined, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/restore-order`, endpoint: `/api/rsforms/${itemID}/restore-order`,
request: { successMessage: information.reorderComplete } request: { successMessage: information.reorderComplete }
}), }),
resetAliases: ({ itemID }: { itemID: LibraryItemID }) => resetAliases: ({ itemID }: { itemID: LibraryItemID }) =>
axiosPatch<undefined, IRSFormData>({ axiosPatch<undefined, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/reset-aliases`, endpoint: `/api/rsforms/${itemID}/reset-aliases`,
request: { successMessage: information.reindexComplete } request: { successMessage: information.reindexComplete }
}), }),

View File

@ -4,9 +4,8 @@ import { DataCallback } from '@/backend/apiTransport';
import { useUpdateTimestamp } from '@/backend/library/useUpdateTimestamp'; import { useUpdateTimestamp } from '@/backend/library/useUpdateTimestamp';
import { ossApi } from '@/backend/oss/api'; import { ossApi } from '@/backend/oss/api';
import { LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { IRSFormData } from '@/models/rsform';
import { IInlineSynthesisDTO, rsformsApi } from './api'; import { IInlineSynthesisDTO, IRSFormDTO, rsformsApi } from './api';
export const useInlineSynthesis = () => { export const useInlineSynthesis = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -33,7 +32,7 @@ export const useInlineSynthesis = () => {
itemID: LibraryItemID; // itemID: LibraryItemID; //
data: IInlineSynthesisDTO; data: IInlineSynthesisDTO;
}, },
onSuccess?: DataCallback<IRSFormData> onSuccess?: DataCallback<IRSFormDTO>
) => mutation.mutate(data, { onSuccess }) ) => mutation.mutate(data, { onSuccess })
}; };
}; };

View File

@ -1,7 +1,7 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { RSFormLoader } from '@/backend/rsform/RSFormLoader';
import { LibraryItemID, VersionID } from '@/models/library'; import { LibraryItemID, VersionID } from '@/models/library';
import { RSFormLoader } from '@/models/RSFormLoader';
import { queryClient } from '../queryClient'; import { queryClient } from '../queryClient';
import { rsformsApi } from './api'; import { rsformsApi } from './api';

View File

@ -1,7 +1,7 @@
import { useQueries } from '@tanstack/react-query'; import { useQueries } from '@tanstack/react-query';
import { RSFormLoader } from '@/backend/rsform/RSFormLoader';
import { LibraryItemID } from '@/models/library'; import { LibraryItemID } from '@/models/library';
import { RSFormLoader } from '@/models/RSFormLoader';
import { DELAYS } from '../configuration'; import { DELAYS } from '../configuration';
import { rsformsApi } from './api'; import { rsformsApi } from './api';

View File

@ -1,14 +1,45 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport'; import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { IUser, IUserInfo, IUserProfile, IUserSignupData } from '@/models/user'; import { IUserInfo, IUserProfile } from '@/models/user';
import { information } from '@/utils/labels'; import { patterns } from '@/utils/constants';
import { errors, information } from '@/utils/labels';
/**
* Represents signup data, used to create new users.
*/
export const UserSignupSchema = z
.object({
username: z.string().nonempty(errors.requiredField).regex(RegExp(patterns.login), errors.loginFormat),
email: z.string().email(errors.emailField),
first_name: z.string(),
last_name: z.string(),
password: z.string().nonempty(errors.requiredField),
password2: z.string().nonempty(errors.requiredField)
})
.refine(schema => schema.password === schema.password2, { path: ['password2'], message: errors.passwordsMismatch });
/**
* Represents signup data, used to create new users.
*/
export type IUserSignupDTO = z.infer<typeof UserSignupSchema>;
/** /**
* Represents user data, intended to update user profile in persistent storage. * Represents user data, intended to update user profile in persistent storage.
*/ */
export interface IUpdateProfileDTO extends Omit<IUser, 'is_staff' | 'id'> {} export const UpdateProfileSchema = z.object({
email: z.string().email(errors.emailField),
first_name: z.string(),
last_name: z.string()
});
/**
* Represents user data, intended to update user profile in persistent storage.
*/
export type IUpdateProfileDTO = z.infer<typeof UpdateProfileSchema>;
export const usersApi = { export const usersApi = {
baseKey: 'users', baseKey: 'users',
@ -33,8 +64,8 @@ export const usersApi = {
}) })
}), }),
signup: (data: IUserSignupData) => signup: (data: IUserSignupDTO) =>
axiosPost<IUserSignupData, IUserProfile>({ axiosPost<IUserSignupDTO, IUserProfile>({
endpoint: '/users/api/signup', endpoint: '/users/api/signup',
request: { request: {
data: data, data: data,

View File

@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { usersApi } from '@/backend/users/api'; import { IUserSignupDTO, usersApi } from '@/backend/users/api';
import { IUserProfile, IUserSignupData } from '@/models/user'; import { IUserProfile } from '@/models/user';
export const useSignup = () => { export const useSignup = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -13,7 +13,7 @@ export const useSignup = () => {
}); });
return { return {
signup: ( signup: (
data: IUserSignupData, // data: IUserSignupDTO, //
onSuccess?: DataCallback<IUserProfile> onSuccess?: DataCallback<IUserProfile>
) => mutation.mutate(data, { onSuccess }), ) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending, isPending: mutation.isPending,

View File

@ -1,5 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { IUserProfile } from '@/models/user';
import { DataCallback } from '../apiTransport';
import { IUpdateProfileDTO, usersApi } from './api'; import { IUpdateProfileDTO, usersApi } from './api';
export const useUpdateProfile = () => { export const useUpdateProfile = () => {
@ -13,7 +16,8 @@ export const useUpdateProfile = () => {
} }
}); });
return { return {
updateProfile: (data: IUpdateProfileDTO) => mutation.mutate(data), updateProfile: (data: IUpdateProfileDTO, onSuccess?: DataCallback<IUserProfile>) =>
mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending, isPending: mutation.isPending,
error: mutation.error, error: mutation.error,
reset: mutation.reset reset: mutation.reset

View File

@ -1,5 +1,6 @@
// =========== Module contains interfaces for common UI elements. ========== // =========== Module contains interfaces for common UI elements. ==========
import React from 'react'; import React from 'react';
import { FieldError } from 'react-hook-form';
export namespace CProps { export namespace CProps {
/** /**
@ -35,6 +36,13 @@ export namespace CProps {
hideTitle?: boolean; hideTitle?: boolean;
} }
/**
* Represents an object that can have an error message.
*/
export interface ErrorProcessing {
error?: FieldError;
}
/** /**
* Represents `control` component with optional title and configuration options. * Represents `control` component with optional title and configuration options.
* *

View File

@ -17,6 +17,9 @@ import { describeConstituenta } from '@/utils/labels';
interface PickConstituentaProps extends CProps.Styling { interface PickConstituentaProps extends CProps.Styling {
id?: string; id?: string;
value?: IConstituenta;
onChange: (newValue: IConstituenta) => void;
prefixID: string; prefixID: string;
data?: IConstituenta[]; data?: IConstituenta[];
rows?: number; rows?: number;
@ -25,9 +28,6 @@ interface PickConstituentaProps extends CProps.Styling {
onBeginFilter?: (cst: IConstituenta) => boolean; onBeginFilter?: (cst: IConstituenta) => boolean;
describeFunc?: (cst: IConstituenta) => string; describeFunc?: (cst: IConstituenta) => string;
matchFunc?: (cst: IConstituenta, filter: string) => boolean; matchFunc?: (cst: IConstituenta, filter: string) => boolean;
value?: IConstituenta;
onSelectValue: (newValue: IConstituenta) => void;
} }
const columnHelper = createColumnHelper<IConstituenta>(); const columnHelper = createColumnHelper<IConstituenta>();
@ -42,7 +42,7 @@ function PickConstituenta({
describeFunc = describeConstituenta, describeFunc = describeConstituenta,
matchFunc = (cst, filter) => matchConstituenta(cst, filter, CstMatchMode.ALL), matchFunc = (cst, filter) => matchConstituenta(cst, filter, CstMatchMode.ALL),
onBeginFilter, onBeginFilter,
onSelectValue, onChange,
className, className,
...restProps ...restProps
}: PickConstituentaProps) { }: PickConstituentaProps) {
@ -110,7 +110,7 @@ function PickConstituenta({
<p>Измените параметры фильтра</p> <p>Измените параметры фильтра</p>
</NoData> </NoData>
} }
onRowClicked={onSelectValue} onRowClicked={onChange}
/> />
</div> </div>
); );

View File

@ -18,15 +18,15 @@ import ToolbarGraphSelection from './ToolbarGraphSelection';
interface PickMultiConstituentaProps extends CProps.Styling { interface PickMultiConstituentaProps extends CProps.Styling {
id?: string; id?: string;
value: ConstituentaID[];
onChange: React.Dispatch<React.SetStateAction<ConstituentaID[]>>;
schema: IRSForm; schema: IRSForm;
data: IConstituenta[]; data: IConstituenta[];
prefixID: string; prefixID: string;
rows?: number; rows?: number;
noBorder?: boolean; noBorder?: boolean;
selected: ConstituentaID[];
setSelected: React.Dispatch<React.SetStateAction<ConstituentaID[]>>;
} }
const columnHelper = createColumnHelper<IConstituenta>(); const columnHelper = createColumnHelper<IConstituenta>();
@ -38,8 +38,8 @@ function PickMultiConstituenta({
prefixID, prefixID,
rows, rows,
noBorder, noBorder,
selected, value,
setSelected, onChange,
className, className,
...restProps ...restProps
}: PickMultiConstituentaProps) { }: PickMultiConstituentaProps) {
@ -74,10 +74,10 @@ function PickMultiConstituenta({
} }
const newRowSelection: RowSelectionState = {}; const newRowSelection: RowSelectionState = {};
filtered.forEach((cst, index) => { filtered.forEach((cst, index) => {
newRowSelection[String(index)] = selected.includes(cst.id); newRowSelection[String(index)] = value.includes(cst.id);
}); });
setRowSelection(newRowSelection); setRowSelection(newRowSelection);
}, [filtered, setRowSelection, selected]); }, [filtered, setRowSelection, value]);
useEffect(() => { useEffect(() => {
if (data.length === 0) { if (data.length === 0) {
@ -91,7 +91,7 @@ function PickMultiConstituenta({
function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) { function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) {
if (!data) { if (!data) {
setSelected([]); onChange([]);
} else { } else {
const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater; const newRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newSelection: ConstituentaID[] = []; const newSelection: ConstituentaID[] = [];
@ -100,7 +100,7 @@ function PickMultiConstituenta({
newSelection.push(cst.id); newSelection.push(cst.id);
} }
}); });
setSelected(prev => [...prev.filter(cst_id => !filtered.find(cst => cst.id === cst_id)), ...newSelection]); onChange(prev => [...prev.filter(cst_id => !filtered.find(cst => cst.id === cst_id)), ...newSelection]);
} }
} }
@ -122,7 +122,7 @@ function PickMultiConstituenta({
<div className={clsx(noBorder ? '' : 'border', className)} {...restProps}> <div className={clsx(noBorder ? '' : 'border', className)} {...restProps}>
<div className={clsx('px-3 flex justify-between items-center', 'clr-input', 'border-b', 'rounded-t-md')}> <div className={clsx('px-3 flex justify-between items-center', 'clr-input', 'border-b', 'rounded-t-md')}>
<div className='w-[24ch] select-none whitespace-nowrap'> <div className='w-[24ch] select-none whitespace-nowrap'>
{data.length > 0 ? `Выбраны ${selected.length} из ${data.length}` : 'Конституенты'} {data.length > 0 ? `Выбраны ${value.length} из ${data.length}` : 'Конституенты'}
</div> </div>
<SearchBar <SearchBar
id='dlg_constituents_search' id='dlg_constituents_search'
@ -135,9 +135,9 @@ function PickMultiConstituenta({
graph={foldedGraph} graph={foldedGraph}
isCore={cstID => isBasicConcept(schema.cstByID.get(cstID)?.cst_type)} isCore={cstID => isBasicConcept(schema.cstByID.get(cstID)?.cst_type)}
isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited} isOwned={cstID => !schema.cstByID.get(cstID)?.is_inherited}
selected={selected} value={value}
setSelected={setSelected} onChange={onChange}
emptySelection={selected.length === 0} emptySelection={value.length === 0}
className='w-fit' className='w-fit'
/> />
</div> </div>

View File

@ -12,36 +12,36 @@ import NoData from '@/components/ui/NoData';
import { IOperation, OperationID } from '@/models/oss'; import { IOperation, OperationID } from '@/models/oss';
interface PickMultiOperationProps extends CProps.Styling { interface PickMultiOperationProps extends CProps.Styling {
rows?: number; value: OperationID[];
onChange: React.Dispatch<React.SetStateAction<OperationID[]>>;
items: IOperation[]; items: IOperation[];
selected: OperationID[]; rows?: number;
setSelected: React.Dispatch<React.SetStateAction<OperationID[]>>;
} }
const columnHelper = createColumnHelper<IOperation>(); const columnHelper = createColumnHelper<IOperation>();
function PickMultiOperation({ rows, items, selected, setSelected, className, ...restProps }: PickMultiOperationProps) { function PickMultiOperation({ rows, items, value, onChange, className, ...restProps }: PickMultiOperationProps) {
const selectedItems = selected.map(itemID => items.find(item => item.id === itemID)!); const selectedItems = value.map(itemID => items.find(item => item.id === itemID)!);
const nonSelectedItems = items.filter(item => !selected.includes(item.id)); const nonSelectedItems = items.filter(item => !value.includes(item.id));
const [lastSelected, setLastSelected] = useState<IOperation | undefined>(undefined); const [lastSelected, setLastSelected] = useState<IOperation | undefined>(undefined);
function handleDelete(operation: OperationID) { function handleDelete(operation: OperationID) {
setSelected(prev => prev.filter(item => item !== operation)); onChange(prev => prev.filter(item => item !== operation));
} }
function handleSelect(operation?: IOperation) { function handleSelect(operation?: IOperation) {
if (operation) { if (operation) {
setLastSelected(operation); setLastSelected(operation);
setSelected(prev => [...prev, operation.id]); onChange(prev => [...prev, operation.id]);
setTimeout(() => setLastSelected(undefined), 1000); setTimeout(() => setLastSelected(undefined), 1000);
} }
} }
function handleMoveUp(operation: OperationID) { function handleMoveUp(operation: OperationID) {
const index = selected.indexOf(operation); const index = value.indexOf(operation);
if (index > 0) { if (index > 0) {
setSelected(prev => { onChange(prev => {
const newSelected = [...prev]; const newSelected = [...prev];
newSelected[index] = newSelected[index - 1]; newSelected[index] = newSelected[index - 1];
newSelected[index - 1] = operation; newSelected[index - 1] = operation;
@ -51,9 +51,9 @@ function PickMultiOperation({ rows, items, selected, setSelected, className, ...
} }
function handleMoveDown(operation: OperationID) { function handleMoveDown(operation: OperationID) {
const index = selected.indexOf(operation); const index = value.indexOf(operation);
if (index < selected.length - 1) { if (index < value.length - 1) {
setSelected(prev => { onChange(prev => {
const newSelected = [...prev]; const newSelected = [...prev];
newSelected[index] = newSelected[index + 1]; newSelected[index] = newSelected[index + 1];
newSelected[index + 1] = operation; newSelected[index + 1] = operation;
@ -118,7 +118,7 @@ function PickMultiOperation({ rows, items, selected, setSelected, className, ...
noBorder noBorder
items={nonSelectedItems} // prettier: split-line items={nonSelectedItems} // prettier: split-line
value={lastSelected} value={lastSelected}
onSelectValue={handleSelect} onChange={handleSelect}
/> />
<DataTable <DataTable
dense dense

View File

@ -19,14 +19,15 @@ import SelectLocation from './SelectLocation';
interface PickSchemaProps extends CProps.Styling { interface PickSchemaProps extends CProps.Styling {
id?: string; id?: string;
value?: LibraryItemID;
onChange: (newValue: LibraryItemID) => void;
initialFilter?: string; initialFilter?: string;
rows?: number; rows?: number;
items: ILibraryItem[]; items: ILibraryItem[];
itemType: LibraryItemType; itemType: LibraryItemType;
value?: LibraryItemID;
baseFilter?: (target: ILibraryItem) => boolean; baseFilter?: (target: ILibraryItem) => boolean;
onSelectValue: (newValue: LibraryItemID) => void;
} }
const columnHelper = createColumnHelper<ILibraryItem>(); const columnHelper = createColumnHelper<ILibraryItem>();
@ -38,7 +39,7 @@ function PickSchema({
items, items,
itemType, itemType,
value, value,
onSelectValue, onChange,
baseFilter, baseFilter,
className, className,
...restProps ...restProps
@ -156,7 +157,7 @@ function PickSchema({
<p>Измените параметры фильтра</p> <p>Измените параметры фильтра</p>
</FlexColumn> </FlexColumn>
} }
onRowClicked={rowData => onSelectValue(rowData.id)} onRowClicked={rowData => onChange(rowData.id)}
/> />
</div> </div>
); );

View File

@ -20,8 +20,9 @@ import { errors } from '@/utils/labels';
import SelectLibraryItem from './SelectLibraryItem'; import SelectLibraryItem from './SelectLibraryItem';
interface PickSubstitutionsProps extends CProps.Styling { interface PickSubstitutionsProps extends CProps.Styling {
substitutions: ICstSubstitute[]; value: ICstSubstitute[];
setSubstitutions: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>; onChange: React.Dispatch<React.SetStateAction<ICstSubstitute[]>>;
suggestions?: ICstSubstitute[]; suggestions?: ICstSubstitute[];
prefixID: string; prefixID: string;
@ -35,8 +36,8 @@ interface PickSubstitutionsProps extends CProps.Styling {
const columnHelper = createColumnHelper<IMultiSubstitution>(); const columnHelper = createColumnHelper<IMultiSubstitution>();
function PickSubstitutions({ function PickSubstitutions({
substitutions, value,
setSubstitutions, onChange,
suggestions, suggestions,
prefixID, prefixID,
rows, rows,
@ -66,7 +67,7 @@ function PickSubstitutions({
) ?? []; ) ?? [];
const substitutionData: IMultiSubstitution[] = [ const substitutionData: IMultiSubstitution[] = [
...substitutions.map(item => ({ ...value.map(item => ({
original_source: getSchemaByCst(item.original)!, original_source: getSchemaByCst(item.original)!,
original: getConstituenta(item.original)!, original: getConstituenta(item.original)!,
substitution: getConstituenta(item.substitution)!, substitution: getConstituenta(item.substitution)!,
@ -110,8 +111,8 @@ function PickSubstitutions({
original: deleteRight ? rightCst.id : leftCst.id, original: deleteRight ? rightCst.id : leftCst.id,
substitution: deleteRight ? leftCst.id : rightCst.id substitution: deleteRight ? leftCst.id : rightCst.id
}; };
const toDelete = substitutions.map(item => item.original); const toDelete = value.map(item => item.original);
const replacements = substitutions.map(item => item.substitution); const replacements = value.map(item => item.substitution);
if ( if (
toDelete.includes(newSubstitution.original) || toDelete.includes(newSubstitution.original) ||
toDelete.includes(newSubstitution.substitution) || toDelete.includes(newSubstitution.substitution) ||
@ -126,7 +127,7 @@ function PickSubstitutions({
return; return;
} }
} }
setSubstitutions(prev => [...prev, newSubstitution]); onChange(prev => [...prev, newSubstitution]);
setLeftCst(undefined); setLeftCst(undefined);
setRightCst(undefined); setRightCst(undefined);
} }
@ -136,12 +137,12 @@ function PickSubstitutions({
} }
function handleAcceptSuggestion(item: IMultiSubstitution) { function handleAcceptSuggestion(item: IMultiSubstitution) {
setSubstitutions(prev => [...prev, { original: item.original.id, substitution: item.substitution.id }]); onChange(prev => [...prev, { original: item.original.id, substitution: item.substitution.id }]);
} }
function handleDeleteSubstitution(target: IMultiSubstitution) { function handleDeleteSubstitution(target: IMultiSubstitution) {
handleDeclineSuggestion(target); handleDeclineSuggestion(target);
setSubstitutions(prev => { onChange(prev => {
const newItems: ICstSubstitute[] = []; const newItems: ICstSubstitute[] = [];
prev.forEach(item => { prev.forEach(item => {
if (item.original !== target.original.id || item.substitution !== target.substitution.id) { if (item.original !== target.original.id || item.substitution !== target.substitution.id) {
@ -230,15 +231,15 @@ function PickSubstitutions({
placeholder='Выберите аргумент' placeholder='Выберите аргумент'
items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== rightArgument?.id)} items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== rightArgument?.id)}
value={leftArgument} value={leftArgument}
onSelectValue={setLeftArgument} onChange={setLeftArgument}
/> />
<SelectConstituenta <SelectConstituenta
noBorder noBorder
items={(leftArgument as IRSForm)?.items.filter( items={(leftArgument as IRSForm)?.items.filter(
cst => !substitutions.find(item => item.original === cst.id) && (!filter || filter(cst)) cst => !value.find(item => item.original === cst.id) && (!filter || filter(cst))
)} )}
value={leftCst} value={leftCst}
onSelectValue={setLeftCst} onChange={setLeftCst}
/> />
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
@ -268,15 +269,15 @@ function PickSubstitutions({
placeholder='Выберите аргумент' placeholder='Выберите аргумент'
items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== leftArgument?.id)} items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== leftArgument?.id)}
value={rightArgument} value={rightArgument}
onSelectValue={setRightArgument} onChange={setRightArgument}
/> />
<SelectConstituenta <SelectConstituenta
noBorder noBorder
items={(rightArgument as IRSForm)?.items.filter( items={(rightArgument as IRSForm)?.items.filter(
cst => !substitutions.find(item => item.original === cst.id) && (!filter || filter(cst)) cst => !value.find(item => item.original === cst.id) && (!filter || filter(cst))
)} )}
value={rightCst} value={rightCst}
onSelectValue={setRightCst} onChange={setRightCst}
/> />
</div> </div>
</div> </div>

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useCallback } from 'react';
import { PolicyIcon } from '@/components/DomainIcons'; import { PolicyIcon } from '@/components/DomainIcons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
@ -15,6 +13,7 @@ import { describeAccessPolicy, labelAccessPolicy } from '@/utils/labels';
interface SelectAccessPolicyProps extends CProps.Styling { interface SelectAccessPolicyProps extends CProps.Styling {
value: AccessPolicy; value: AccessPolicy;
onChange: (value: AccessPolicy) => void; onChange: (value: AccessPolicy) => void;
disabled?: boolean; disabled?: boolean;
stretchLeft?: boolean; stretchLeft?: boolean;
} }
@ -22,15 +21,12 @@ interface SelectAccessPolicyProps extends CProps.Styling {
function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...restProps }: SelectAccessPolicyProps) { function SelectAccessPolicy({ value, disabled, stretchLeft, onChange, ...restProps }: SelectAccessPolicyProps) {
const menu = useDropdown(); const menu = useDropdown();
const handleChange = useCallback( function handleChange(newValue: AccessPolicy) {
(newValue: AccessPolicy) => {
menu.hide(); menu.hide();
if (newValue !== value) { if (newValue !== value) {
onChange(newValue); onChange(newValue);
} }
}, }
[menu, value, onChange]
);
return ( return (
<div ref={menu.ref} {...restProps}> <div ref={menu.ref} {...restProps}>

View File

@ -10,10 +10,10 @@ import { matchConstituenta } from '@/models/rsformAPI';
import { describeConstituenta, describeConstituentaTerm } from '@/utils/labels'; import { describeConstituenta, describeConstituentaTerm } from '@/utils/labels';
interface SelectConstituentaProps extends CProps.Styling { interface SelectConstituentaProps extends CProps.Styling {
items?: IConstituenta[];
value?: IConstituenta; value?: IConstituenta;
onSelectValue: (newValue?: IConstituenta) => void; onChange: (newValue?: IConstituenta) => void;
items?: IConstituenta[];
placeholder?: string; placeholder?: string;
noBorder?: boolean; noBorder?: boolean;
} }
@ -22,7 +22,7 @@ function SelectConstituenta({
className, className,
items, items,
value, value,
onSelectValue, onChange,
placeholder = 'Выберите конституенту', placeholder = 'Выберите конституенту',
...restProps ...restProps
}: SelectConstituentaProps) { }: SelectConstituentaProps) {
@ -42,7 +42,7 @@ function SelectConstituenta({
className={clsx('text-ellipsis', className)} className={clsx('text-ellipsis', className)}
options={options} options={options}
value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : null} value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))} onChange={data => onChange(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter} filterOption={filter}
placeholder={placeholder} placeholder={placeholder}

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useCallback } from 'react';
import { DependencyIcon } from '@/components/DomainIcons'; import { DependencyIcon } from '@/components/DomainIcons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
@ -15,21 +13,18 @@ import { describeCstSource, labelCstSource } from '@/utils/labels';
interface SelectGraphFilterProps extends CProps.Styling { interface SelectGraphFilterProps extends CProps.Styling {
value: DependencyMode; value: DependencyMode;
dense?: boolean;
onChange: (value: DependencyMode) => void; onChange: (value: DependencyMode) => void;
dense?: boolean;
} }
function SelectGraphFilter({ value, dense, onChange, ...restProps }: SelectGraphFilterProps) { function SelectGraphFilter({ value, dense, onChange, ...restProps }: SelectGraphFilterProps) {
const menu = useDropdown(); const menu = useDropdown();
const size = useWindowSize(); const size = useWindowSize();
const handleChange = useCallback( function handleChange(newValue: DependencyMode) {
(newValue: DependencyMode) => {
menu.hide(); menu.hide();
onChange(newValue); onChange(newValue);
}, }
[menu, onChange]
);
return ( return (
<div ref={menu.ref} {...restProps}> <div ref={menu.ref} {...restProps}>

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useCallback } from 'react';
import { ItemTypeIcon } from '@/components/DomainIcons'; import { ItemTypeIcon } from '@/components/DomainIcons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
@ -22,15 +20,12 @@ interface SelectItemTypeProps extends CProps.Styling {
function SelectItemType({ value, disabled, stretchLeft, onChange, ...restProps }: SelectItemTypeProps) { function SelectItemType({ value, disabled, stretchLeft, onChange, ...restProps }: SelectItemTypeProps) {
const menu = useDropdown(); const menu = useDropdown();
const handleChange = useCallback( function handleChange(newValue: LibraryItemType) {
(newValue: LibraryItemType) => {
menu.hide(); menu.hide();
if (newValue !== value) { if (newValue !== value) {
onChange(newValue); onChange(newValue);
} }
}, }
[menu, value, onChange]
);
return ( return (
<div ref={menu.ref} {...restProps}> <div ref={menu.ref} {...restProps}>

View File

@ -10,7 +10,7 @@ import { matchLibraryItem } from '@/models/libraryAPI';
interface SelectLibraryItemProps extends CProps.Styling { interface SelectLibraryItemProps extends CProps.Styling {
items?: ILibraryItem[]; items?: ILibraryItem[];
value?: ILibraryItem; value?: ILibraryItem;
onSelectValue: (newValue?: ILibraryItem) => void; onChange: (newValue?: ILibraryItem) => void;
placeholder?: string; placeholder?: string;
noBorder?: boolean; noBorder?: boolean;
@ -20,7 +20,7 @@ function SelectLibraryItem({
className, className,
items, items,
value, value,
onSelectValue, onChange,
placeholder = 'Выберите схему', placeholder = 'Выберите схему',
...restProps ...restProps
}: SelectLibraryItemProps) { }: SelectLibraryItemProps) {
@ -40,7 +40,7 @@ function SelectLibraryItem({
className={clsx('text-ellipsis', className)} className={clsx('text-ellipsis', className)}
options={options} options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null} value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))} onChange={data => onChange(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter} filterOption={filter}
placeholder={placeholder} placeholder={placeholder}

View File

@ -12,9 +12,10 @@ import { labelFolderNode } from '@/utils/labels';
interface SelectLocationProps extends CProps.Styling { interface SelectLocationProps extends CProps.Styling {
value: string; value: string;
onClick: (event: CProps.EventMouse, target: FolderNode) => void;
prefix: string; prefix: string;
dense?: boolean; dense?: boolean;
onClick: (event: CProps.EventMouse, target: FolderNode) => void;
} }
function SelectLocation({ value, dense, prefix, onClick, className, style }: SelectLocationProps) { function SelectLocation({ value, dense, prefix, onClick, className, style }: SelectLocationProps) {

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback } from 'react';
import { IconFolderTree } from '@/components/Icons'; import { IconFolderTree } from '@/components/Icons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
@ -14,10 +13,9 @@ import SelectLocation from './SelectLocation';
interface SelectLocationContextProps extends CProps.Styling { interface SelectLocationContextProps extends CProps.Styling {
value: string; value: string;
onChange: (newValue: string) => void;
title?: string; title?: string;
stretchTop?: boolean; stretchTop?: boolean;
onChange: (newValue: string) => void;
} }
function SelectLocationContext({ function SelectLocationContext({
@ -29,15 +27,12 @@ function SelectLocationContext({
}: SelectLocationContextProps) { }: SelectLocationContextProps) {
const menu = useDropdown(); const menu = useDropdown();
const handleClick = useCallback( function handleClick(event: CProps.EventMouse, newValue: string) {
(event: CProps.EventMouse, newValue: string) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
menu.hide(); menu.hide();
onChange(newValue); onChange(newValue);
}, }
[menu, onChange]
);
return ( return (
<div ref={menu.ref} className='h-full text-right self-start mt-[-0.25rem] ml-[-1.5rem]'> <div ref={menu.ref} className='h-full text-right self-start mt-[-0.25rem] ml-[-1.5rem]'>

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback } from 'react';
import { LocationIcon } from '@/components/DomainIcons'; import { LocationIcon } from '@/components/DomainIcons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
@ -22,13 +21,10 @@ interface SelectLocationHeadProps extends CProps.Styling {
function SelectLocationHead({ value, excluded = [], onChange, className, ...restProps }: SelectLocationHeadProps) { function SelectLocationHead({ value, excluded = [], onChange, className, ...restProps }: SelectLocationHeadProps) {
const menu = useDropdown(); const menu = useDropdown();
const handleChange = useCallback( function handleChange(newValue: LocationHead) {
(newValue: LocationHead) => {
menu.hide(); menu.hide();
onChange(newValue); onChange(newValue);
}, }
[menu, onChange]
);
return ( return (
<div ref={menu.ref} className={clsx('h-full text-right', className)} {...restProps}> <div ref={menu.ref} className={clsx('h-full text-right', className)} {...restProps}>

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useCallback } from 'react';
import { MatchModeIcon } from '@/components/DomainIcons'; import { MatchModeIcon } from '@/components/DomainIcons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
@ -15,21 +13,18 @@ import { describeCstMatchMode, labelCstMatchMode } from '@/utils/labels';
interface SelectMatchModeProps extends CProps.Styling { interface SelectMatchModeProps extends CProps.Styling {
value: CstMatchMode; value: CstMatchMode;
dense?: boolean;
onChange: (value: CstMatchMode) => void; onChange: (value: CstMatchMode) => void;
dense?: boolean;
} }
function SelectMatchMode({ value, dense, onChange, ...restProps }: SelectMatchModeProps) { function SelectMatchMode({ value, dense, onChange, ...restProps }: SelectMatchModeProps) {
const menu = useDropdown(); const menu = useDropdown();
const size = useWindowSize(); const size = useWindowSize();
const handleChange = useCallback( function handleChange(newValue: CstMatchMode) {
(newValue: CstMatchMode) => {
menu.hide(); menu.hide();
onChange(newValue); onChange(newValue);
}, }
[menu, onChange]
);
return ( return (
<div ref={menu.ref} {...restProps}> <div ref={menu.ref} {...restProps}>

View File

@ -10,11 +10,11 @@ interface SelectMultiGrammemeProps
extends Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'>, extends Omit<SelectMultiProps<IGrammemeOption>, 'value' | 'onChange'>,
CProps.Styling { CProps.Styling {
value: IGrammemeOption[]; value: IGrammemeOption[];
onChangeValue: (newValue: IGrammemeOption[]) => void; onChange: (newValue: IGrammemeOption[]) => void;
placeholder?: string; placeholder?: string;
} }
function SelectMultiGrammeme({ value, onChangeValue, ...restProps }: SelectMultiGrammemeProps) { function SelectMultiGrammeme({ value, onChange, ...restProps }: SelectMultiGrammemeProps) {
const [options, setOptions] = useState<IGrammemeOption[]>([]); const [options, setOptions] = useState<IGrammemeOption[]>([]);
useEffect(() => { useEffect(() => {
@ -28,7 +28,7 @@ function SelectMultiGrammeme({ value, onChangeValue, ...restProps }: SelectMulti
<SelectMulti <SelectMulti
options={options} options={options}
value={value} value={value}
onChange={newValue => onChangeValue([...newValue].sort(compareGrammemeOptions))} onChange={newValue => onChange([...newValue].sort(compareGrammemeOptions))}
{...restProps} {...restProps}
/> />
); );

View File

@ -10,7 +10,7 @@ import { matchOperation } from '@/models/ossAPI';
interface SelectOperationProps extends CProps.Styling { interface SelectOperationProps extends CProps.Styling {
items?: IOperation[]; items?: IOperation[];
value?: IOperation; value?: IOperation;
onSelectValue: (newValue?: IOperation) => void; onChange: (newValue?: IOperation) => void;
placeholder?: string; placeholder?: string;
noBorder?: boolean; noBorder?: boolean;
@ -20,7 +20,7 @@ function SelectOperation({
className, className,
items, items,
value, value,
onSelectValue, onChange,
placeholder = 'Выберите операцию', placeholder = 'Выберите операцию',
...restProps ...restProps
}: SelectOperationProps) { }: SelectOperationProps) {
@ -40,7 +40,7 @@ function SelectOperation({
className={clsx('text-ellipsis', className)} className={clsx('text-ellipsis', className)}
options={options} options={options}
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null} value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
onChange={data => onSelectValue(items?.find(cst => cst.id === data?.value))} onChange={data => onChange(items?.find(cst => cst.id === data?.value))}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter} filterOption={filter}
placeholder={placeholder} placeholder={placeholder}

View File

@ -11,7 +11,7 @@ import { matchUser } from '@/models/userAPI';
interface SelectUserProps extends CProps.Styling { interface SelectUserProps extends CProps.Styling {
value?: UserID; value?: UserID;
onSelectValue: (newValue: UserID) => void; onChange: (newValue: UserID) => void;
filter?: (userID: UserID) => boolean; filter?: (userID: UserID) => boolean;
placeholder?: string; placeholder?: string;
@ -22,7 +22,7 @@ function SelectUser({
className, className,
filter, filter,
value, value,
onSelectValue, onChange,
placeholder = 'Выберите пользователя', placeholder = 'Выберите пользователя',
...restProps ...restProps
}: SelectUserProps) { }: SelectUserProps) {
@ -46,7 +46,7 @@ function SelectUser({
options={options} options={options}
value={value ? { value: value, label: getUserLabel(value) } : null} value={value ? { value: value, label: getUserLabel(value) } : null}
onChange={data => { onChange={data => {
if (data?.value !== undefined) onSelectValue(data.value); if (data?.value !== undefined) onChange(data.value);
}} }}
// @ts-expect-error: TODO: use type definitions from react-select in filter object // @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filterLabel} filterOption={filterLabel}

View File

@ -11,13 +11,13 @@ interface SelectVersionProps extends CProps.Styling {
id?: string; id?: string;
items?: IVersionInfo[]; items?: IVersionInfo[];
value?: VersionID; value?: VersionID;
onSelectValue: (newValue?: VersionID) => void; onChange: (newValue?: VersionID) => void;
placeholder?: string; placeholder?: string;
noBorder?: boolean; noBorder?: boolean;
} }
function SelectVersion({ id, className, items, value, onSelectValue, ...restProps }: SelectVersionProps) { function SelectVersion({ id, className, items, value, onChange, ...restProps }: SelectVersionProps) {
const options = [ const options = [
{ {
value: undefined, value: undefined,
@ -40,7 +40,7 @@ function SelectVersion({ id, className, items, value, onSelectValue, ...restProp
className={clsx('min-w-[12rem] text-ellipsis', className)} className={clsx('min-w-[12rem] text-ellipsis', className)}
options={options} options={options}
value={{ value: value, label: valueLabel }} value={{ value: value, label: valueLabel }}
onChange={data => onSelectValue(data?.value)} onChange={data => onChange(data?.value)}
{...restProps} {...restProps}
/> />
); );

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback } from 'react';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import WordformButton from '@/dialogs/DlgEditReference/WordformButton'; import WordformButton from '@/dialogs/DlgEditReference/WordformButton';
@ -10,17 +9,14 @@ import { prefixes } from '@/utils/constants';
import { DefaultWordForms, IGrammemeOption, SelectorGrammemes } from '@/utils/selectors'; import { DefaultWordForms, IGrammemeOption, SelectorGrammemes } from '@/utils/selectors';
interface SelectWordFormProps extends CProps.Styling { interface SelectWordFormProps extends CProps.Styling {
selected: IGrammemeOption[]; value: IGrammemeOption[];
setSelected: React.Dispatch<React.SetStateAction<IGrammemeOption[]>>; onChange: React.Dispatch<React.SetStateAction<IGrammemeOption[]>>;
} }
function SelectWordForm({ selected, setSelected, className, ...restProps }: SelectWordFormProps) { function SelectWordForm({ value, onChange, className, ...restProps }: SelectWordFormProps) {
const handleSelect = useCallback( function handleSelect(grams: Grammeme[]) {
(grams: Grammeme[]) => { onChange(SelectorGrammemes.filter(({ value }) => grams.includes(value as Grammeme)));
setSelected(SelectorGrammemes.filter(({ value }) => grams.includes(value as Grammeme))); }
},
[setSelected]
);
return ( return (
<div className={clsx('text-xs sm:text-sm', className)} {...restProps}> <div className={clsx('text-xs sm:text-sm', className)} {...restProps}>
@ -30,7 +26,7 @@ function SelectWordForm({ selected, setSelected, className, ...restProps }: Sele
text={data.text} text={data.text}
example={data.example} example={data.example}
grams={data.grams} grams={data.grams}
isSelected={data.grams.every(gram => selected.find(item => (item.value as Grammeme) === gram))} isSelected={data.grams.every(gram => value.find(item => (item.value as Grammeme) === gram))}
onSelectGrams={handleSelect} onSelectGrams={handleSelect}
/> />
))} ))}

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback } from 'react';
import { import {
IconGraphCollapse, IconGraphCollapse,
@ -17,81 +16,75 @@ import MiniButton from '@/components/ui/MiniButton';
import { Graph } from '@/models/Graph'; import { Graph } from '@/models/Graph';
interface ToolbarGraphSelectionProps extends CProps.Styling { interface ToolbarGraphSelectionProps extends CProps.Styling {
value: number[];
onChange: (newSelection: number[]) => void;
graph: Graph; graph: Graph;
selected: number[];
isCore: (item: number) => boolean; isCore: (item: number) => boolean;
isOwned?: (item: number) => boolean; isOwned?: (item: number) => boolean;
setSelected: (newSelection: number[]) => void;
emptySelection?: boolean; emptySelection?: boolean;
} }
function ToolbarGraphSelection({ function ToolbarGraphSelection({
className, className,
graph, graph,
selected, value: selected,
isCore, isCore,
isOwned, isOwned,
setSelected, onChange,
emptySelection, emptySelection,
...restProps ...restProps
}: ToolbarGraphSelectionProps) { }: ToolbarGraphSelectionProps) {
const handleSelectCore = useCallback(() => { function handleSelectCore() {
const core = [...graph.nodes.keys()].filter(isCore); const core = [...graph.nodes.keys()].filter(isCore);
setSelected([...core, ...graph.expandInputs(core)]); onChange([...core, ...graph.expandInputs(core)]);
}, [setSelected, graph, isCore]); }
const handleSelectOwned = useCallback( function handleSelectOwned() {
() => (isOwned ? setSelected([...graph.nodes.keys()].filter(isOwned)) : undefined), if (isOwned) onChange([...graph.nodes.keys()].filter(isOwned));
[setSelected, graph, isOwned] }
);
const handleInvertSelection = useCallback(
() => setSelected([...graph.nodes.keys()].filter(item => !selected.includes(item))),
[setSelected, selected, graph]
);
return ( return (
<div className={clsx('cc-icons', className)} {...restProps}> <div className={clsx('cc-icons', className)} {...restProps}>
<MiniButton <MiniButton
titleHtml='Сбросить выделение' titleHtml='Сбросить выделение'
icon={<IconReset size='1.25rem' className='icon-primary' />} icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={() => setSelected([])} onClick={() => onChange([])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить все влияющие' titleHtml='Выделить все влияющие'
icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />} icon={<IconGraphCollapse size='1.25rem' className='icon-primary' />}
onClick={() => setSelected([...selected, ...graph.expandAllInputs(selected)])} onClick={() => onChange([...selected, ...graph.expandAllInputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить все зависимые' titleHtml='Выделить все зависимые'
icon={<IconGraphExpand size='1.25rem' className='icon-primary' />} icon={<IconGraphExpand size='1.25rem' className='icon-primary' />}
onClick={() => setSelected([...selected, ...graph.expandAllOutputs(selected)])} onClick={() => onChange([...selected, ...graph.expandAllOutputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных' titleHtml='<b>Максимизация</b> <br/>дополнение выделения конституентами, <br/>зависимыми только от выделенных'
icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />} icon={<IconGraphMaximize size='1.25rem' className='icon-primary' />}
onClick={() => setSelected(graph.maximizePart(selected))} onClick={() => onChange(graph.maximizePart(selected))}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить поставщиков' titleHtml='Выделить поставщиков'
icon={<IconGraphInputs size='1.25rem' className='icon-primary' />} icon={<IconGraphInputs size='1.25rem' className='icon-primary' />}
onClick={() => setSelected([...selected, ...graph.expandInputs(selected)])} onClick={() => onChange([...selected, ...graph.expandInputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Выделить потребителей' titleHtml='Выделить потребителей'
icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />} icon={<IconGraphOutputs size='1.25rem' className='icon-primary' />}
onClick={() => setSelected([...selected, ...graph.expandOutputs(selected)])} onClick={() => onChange([...selected, ...graph.expandOutputs(selected)])}
disabled={emptySelection} disabled={emptySelection}
/> />
<MiniButton <MiniButton
titleHtml='Инвертировать' titleHtml='Инвертировать'
icon={<IconGraphInverse size='1.25rem' className='icon-primary' />} icon={<IconGraphInverse size='1.25rem' className='icon-primary' />}
onClick={handleInvertSelection} onClick={() => onChange([...graph.nodes.keys()].filter(item => !selected.includes(item)))}
/> />
<MiniButton <MiniButton
titleHtml='Выделить ядро' titleHtml='Выделить ядро'

View File

@ -4,7 +4,7 @@ import { CheckboxChecked } from '@/components/Icons';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import { globals } from '@/utils/constants'; import { globals } from '@/utils/constants';
export interface CheckboxProps extends Omit<CProps.Button, 'value' | 'onClick'> { export interface CheckboxProps extends Omit<CProps.Button, 'value' | 'onClick' | 'onChange'> {
/** Label to display next to the checkbox. */ /** Label to display next to the checkbox. */
label?: string; label?: string;
@ -12,10 +12,10 @@ export interface CheckboxProps extends Omit<CProps.Button, 'value' | 'onClick'>
disabled?: boolean; disabled?: boolean;
/** Current value - `true` or `false`. */ /** Current value - `true` or `false`. */
value: boolean; value?: boolean;
/** Callback to set the `value`. */ /** Callback to set the `value`. */
setValue?: (newValue: boolean) => void; onChange?: (newValue: boolean) => void;
} }
/** /**
@ -29,18 +29,18 @@ function Checkbox({
hideTitle, hideTitle,
className, className,
value, value,
setValue, onChange,
...restProps ...restProps
}: CheckboxProps) { }: CheckboxProps) {
const cursor = disabled ? 'cursor-arrow' : setValue ? 'cursor-pointer' : ''; const cursor = disabled ? 'cursor-arrow' : onChange ? 'cursor-pointer' : '';
function handleClick(event: CProps.EventMouse): void { function handleClick(event: CProps.EventMouse): void {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (disabled || !setValue) { if (disabled || !onChange) {
return; return;
} }
setValue(!value); onChange(!value);
} }
return ( return (

View File

@ -6,12 +6,12 @@ import { globals } from '@/utils/constants';
import { CheckboxProps } from './Checkbox'; import { CheckboxProps } from './Checkbox';
export interface CheckboxTristateProps extends Omit<CheckboxProps, 'value' | 'setValue'> { export interface CheckboxTristateProps extends Omit<CheckboxProps, 'value' | 'onChange'> {
/** Current value - `null`, `true` or `false`. */ /** Current value - `null`, `true` or `false`. */
value: boolean | null; value: boolean | null;
/** Callback to set the `value`. */ /** Callback to set the `value`. */
setValue?: (newValue: boolean | null) => void; onChange?: (newValue: boolean | null) => void;
} }
/** /**
@ -25,23 +25,23 @@ function CheckboxTristate({
hideTitle, hideTitle,
className, className,
value, value,
setValue, onChange,
...restProps ...restProps
}: CheckboxTristateProps) { }: CheckboxTristateProps) {
const cursor = disabled ? 'cursor-arrow' : setValue ? 'cursor-pointer' : ''; const cursor = disabled ? 'cursor-arrow' : onChange ? 'cursor-pointer' : '';
function handleClick(event: CProps.EventMouse): void { function handleClick(event: CProps.EventMouse): void {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (disabled || !setValue) { if (disabled || !onChange) {
return; return;
} }
if (value === false) { if (value === false) {
setValue(null); onChange(null);
} else if (value === null) { } else if (value === null) {
setValue(true); onChange(true);
} else { } else {
setValue(false); onChange(false);
} }
} }

View File

@ -22,7 +22,7 @@ function SelectAll<TData>({ table, resetLastSelected }: SelectAllProps<TData>) {
value={ value={
!table.getIsAllPageRowsSelected() && table.getIsSomePageRowsSelected() ? null : table.getIsAllPageRowsSelected() !table.getIsAllPageRowsSelected() && table.getIsSomePageRowsSelected() ? null : table.getIsAllPageRowsSelected()
} }
setValue={handleChange} onChange={handleChange}
/> />
); );
} }

View File

@ -15,7 +15,7 @@ function SelectRow<TData>({ row, onChangeLastSelected }: SelectRowProps<TData>)
row.toggleSelected(value); row.toggleSelected(value);
} }
return <Checkbox tabIndex={-1} value={row.getIsSelected()} setValue={handleChange} />; return <Checkbox tabIndex={-1} value={row.getIsSelected()} onChange={handleChange} />;
} }
export default SelectRow; export default SelectRow;

View File

@ -3,7 +3,7 @@ import clsx from 'clsx';
import Checkbox, { CheckboxProps } from './Checkbox'; import Checkbox, { CheckboxProps } from './Checkbox';
/** Animated {@link Checkbox} inside a {@link Dropdown} item. */ /** Animated {@link Checkbox} inside a {@link Dropdown} item. */
function DropdownCheckbox({ setValue, disabled, ...restProps }: CheckboxProps) { function DropdownCheckbox({ onChange: setValue, disabled, ...restProps }: CheckboxProps) {
return ( return (
<div <div
className={clsx( className={clsx(
@ -13,7 +13,7 @@ function DropdownCheckbox({ setValue, disabled, ...restProps }: CheckboxProps) {
!!setValue && !disabled && 'clr-hover' !!setValue && !disabled && 'clr-hover'
)} )}
> >
<Checkbox tabIndex={-1} disabled={disabled} setValue={setValue} {...restProps} /> <Checkbox tabIndex={-1} disabled={disabled} onChange={setValue} {...restProps} />
</div> </div>
); );
} }

View File

@ -0,0 +1,24 @@
import clsx from 'clsx';
import { FieldError, GlobalError } from 'react-hook-form';
import { CProps } from '../props';
interface ErrorFieldProps extends CProps.Styling {
error?: FieldError | GlobalError;
}
/**
* Displays an error message for input field.
*/
function ErrorField({ error, className, ...restProps }: ErrorFieldProps): React.ReactElement | null {
if (!error) {
return null;
}
return (
<div className={clsx('text-sm text-warn-600 select-none', className)} {...restProps}>
{error.message}
</div>
);
}
export default ErrorField;

View File

@ -19,7 +19,7 @@ interface SelectTreeProps<ItemType> extends CProps.Styling {
prefix: string; prefix: string;
/** Callback to be called when the value changes. */ /** Callback to be called when the value changes. */
onChangeValue: (newItem: ItemType) => void; onChange: (newItem: ItemType) => void;
/** Callback providing the parent of the item. */ /** Callback providing the parent of the item. */
getParent: (item: ItemType) => ItemType; getParent: (item: ItemType) => ItemType;
@ -40,7 +40,7 @@ function SelectTree<ItemType>({
getParent, getParent,
getLabel, getLabel,
getDescription, getDescription,
onChangeValue, onChange,
prefix, prefix,
...restProps ...restProps
}: SelectTreeProps<ItemType>) { }: SelectTreeProps<ItemType>) {
@ -75,7 +75,7 @@ function SelectTree<ItemType>({
function handleSetValue(event: CProps.EventMouse, target: ItemType) { function handleSetValue(event: CProps.EventMouse, target: ItemType) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onChangeValue(target); onChange(target);
} }
return ( return (

View File

@ -2,9 +2,10 @@ import clsx from 'clsx';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import ErrorField from './ErrorField';
import Label from './Label'; import Label from './Label';
export interface TextAreaProps extends CProps.Editor, CProps.Colors, CProps.TextArea { export interface TextAreaProps extends CProps.Editor, CProps.ErrorProcessing, CProps.Colors, CProps.TextArea {
/** Indicates that padding should be minimal. */ /** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
@ -29,6 +30,7 @@ function TextArea({
noResize, noResize,
className, className,
fitContent, fitContent,
error,
colors = 'clr-input', colors = 'clr-input',
...restProps ...restProps
}: TextAreaProps) { }: TextAreaProps) {
@ -37,7 +39,7 @@ function TextArea({
className={clsx( className={clsx(
'w-full', 'w-full',
{ {
'flex flex-col gap-2': !dense, 'flex flex-col': !dense,
'flex flex-grow items-center gap-3': dense 'flex flex-grow items-center gap-3': dense
}, },
dense && className dense && className
@ -55,6 +57,7 @@ function TextArea({
'resize-none': noResize, 'resize-none': noResize,
'border': !noBorder, 'border': !noBorder,
'flex-grow max-w-full': dense, 'flex-grow max-w-full': dense,
'mt-2': !dense,
'clr-outline': !noOutline 'clr-outline': !noOutline
}, },
colors, colors,
@ -64,6 +67,7 @@ function TextArea({
required={required} required={required}
{...restProps} {...restProps}
/> />
<ErrorField className='mt-1' error={error} />
</div> </div>
); );
} }

View File

@ -2,9 +2,10 @@ import clsx from 'clsx';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import ErrorField from './ErrorField';
import Label from './Label'; import Label from './Label';
interface TextInputProps extends CProps.Editor, CProps.Colors, CProps.Input { interface TextInputProps extends CProps.Editor, CProps.ErrorProcessing, CProps.Colors, CProps.Input {
/** Indicates that padding should be minimal. */ /** Indicates that padding should be minimal. */
dense?: boolean; dense?: boolean;
@ -32,13 +33,14 @@ function TextInput({
className, className,
colors = 'clr-input', colors = 'clr-input',
onKeyDown, onKeyDown,
error,
...restProps ...restProps
}: TextInputProps) { }: TextInputProps) {
return ( return (
<div <div
className={clsx( className={clsx(
{ {
'flex flex-col gap-2': !dense, 'flex flex-col': !dense,
'flex items-center gap-3': dense 'flex items-center gap-3': dense
}, },
dense && className dense && className
@ -53,6 +55,7 @@ function TextInput({
{ {
'px-3': !noBorder || !disabled, 'px-3': !noBorder || !disabled,
'flex-grow max-w-full': dense, 'flex-grow max-w-full': dense,
'mt-2': !dense,
'border': !noBorder, 'border': !noBorder,
'clr-outline': !noOutline 'clr-outline': !noOutline
}, },
@ -63,6 +66,7 @@ function TextInput({
disabled={disabled} disabled={disabled}
{...restProps} {...restProps}
/> />
<ErrorField className='mt-1' error={error} />
</div> </div>
); );
} }

View File

@ -61,7 +61,7 @@ function DlgChangeInputSchema() {
items={sortedItems} items={sortedItems}
itemType={LibraryItemType.RSFORM} itemType={LibraryItemType.RSFORM}
value={selected} // prettier: split-line value={selected} // prettier: split-line
onSelectValue={handleSelectLocation} onChange={handleSelectLocation}
rows={14} rows={14}
baseFilter={baseFilter} baseFilter={baseFilter}
/> />

View File

@ -48,7 +48,7 @@ function DlgChangeLocation() {
<SelectLocationHead <SelectLocationHead
value={head} // prettier: split-lines value={head} // prettier: split-lines
onChange={setHead} onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []}
/> />
</div> </div>
<SelectLocationContext value={location} onChange={handleSelectLocation} className='max-h-[9.2rem]' /> <SelectLocationContext value={location} onChange={handleSelectLocation} className='max-h-[9.2rem]' />

View File

@ -6,7 +6,7 @@ import { useState } from 'react';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuthSuspense } from '@/backend/auth/useAuth'; import { useAuthSuspense } from '@/backend/auth/useAuth';
import { IRSFormCloneDTO } from '@/backend/library/api'; import { IRCloneLibraryItemDTO } from '@/backend/library/api';
import { useCloneItem } from '@/backend/library/useCloneItem'; import { useCloneItem } from '@/backend/library/useCloneItem';
import { VisibilityIcon } from '@/components/DomainIcons'; import { VisibilityIcon } from '@/components/DomainIcons';
import SelectAccessPolicy from '@/components/select/SelectAccessPolicy'; import SelectAccessPolicy from '@/components/select/SelectAccessPolicy';
@ -59,7 +59,7 @@ function DlgCloneLibraryItem() {
} }
function handleSubmit() { function handleSubmit() {
const data: IRSFormCloneDTO = { const data: IRCloneLibraryItemDTO = {
id: base.id, id: base.id,
item_type: base.item_type, item_type: base.item_type,
title: title, title: title,
@ -119,11 +119,7 @@ function DlgCloneLibraryItem() {
<div className='flex justify-between gap-3'> <div className='flex justify-between gap-3'>
<div className='flex flex-col gap-2 w-[7rem] h-min'> <div className='flex flex-col gap-2 w-[7rem] h-min'>
<Label text='Корень' /> <Label text='Корень' />
<SelectLocationHead <SelectLocationHead value={head} onChange={setHead} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []} />
value={head}
onChange={setHead}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []}
/>
</div> </div>
<SelectLocationContext value={location} onChange={handleSelectLocation} /> <SelectLocationContext value={location} onChange={handleSelectLocation} />
<TextArea <TextArea
@ -141,7 +137,7 @@ function DlgCloneLibraryItem() {
id='dlg_only_selected' id='dlg_only_selected'
label={`Только выбранные конституенты [${selected.length} из ${totalCount}]`} label={`Только выбранные конституенты [${selected.length} из ${totalCount}]`}
value={onlySelected} value={onlySelected}
setValue={value => setOnlySelected(value)} onChange={value => setOnlySelected(value)}
/> />
</Modal> </Modal>
); );

View File

@ -98,7 +98,7 @@ function TabInputOperation({
</div> </div>
<Checkbox <Checkbox
value={createSchema} value={createSchema}
setValue={onChangeCreateSchema} onChange={onChangeCreateSchema}
label='Создать новую схему' label='Создать новую схему'
titleHtml='Создать пустую схему для загрузки' titleHtml='Создать пустую схему для загрузки'
/> />
@ -108,7 +108,7 @@ function TabInputOperation({
items={sortedItems} items={sortedItems}
value={attachedID} value={attachedID}
itemType={LibraryItemType.RSFORM} itemType={LibraryItemType.RSFORM}
onSelectValue={onChangeAttachedID} onChange={onChangeAttachedID}
rows={8} rows={8}
baseFilter={baseFilter} baseFilter={baseFilter}
/> />

View File

@ -57,7 +57,7 @@ function TabSynthesisOperation({
<FlexColumn> <FlexColumn>
<Label text={`Выбор аргументов: [ ${inputs.length} ]`} /> <Label text={`Выбор аргументов: [ ${inputs.length} ]`} />
<PickMultiOperation items={oss.items} selected={inputs} setSelected={setInputs} rows={6} /> <PickMultiOperation items={oss.items} value={inputs} onChange={setInputs} rows={6} />
</FlexColumn> </FlexColumn>
</div> </div>
); );

View File

@ -64,7 +64,7 @@ function DlgCreateVersion() {
id='dlg_only_selected' id='dlg_only_selected'
label={`Только выбранные конституенты [${selected.length} из ${totalCount}]`} label={`Только выбранные конституенты [${selected.length} из ${totalCount}]`}
value={onlySelected} value={onlySelected}
setValue={value => setOnlySelected(value)} onChange={value => setOnlySelected(value)}
/> />
</Modal> </Modal>
); );

View File

@ -190,7 +190,7 @@ function TabArguments({ state, schema, partialUpdate }: TabArgumentsProps) {
id='dlg_argument_picker' id='dlg_argument_picker'
value={selectedCst} value={selectedCst}
data={schema.items} data={schema.items}
onSelectValue={handleSelectConstituenta} onChange={handleSelectConstituenta}
prefixID={prefixes.cst_modal_list} prefixID={prefixes.cst_modal_list}
rows={7} rows={7}
/> />

View File

@ -102,7 +102,7 @@ function TabTemplate({ state, partialUpdate, templateSchema }: TabTemplateProps)
id='dlg_template_picker' id='dlg_template_picker'
value={state.prototype} value={state.prototype}
data={filteredData} data={filteredData}
onSelectValue={cst => partialUpdate({ prototype: cst })} onChange={cst => partialUpdate({ prototype: cst })}
prefixID={prefixes.cst_template_ist} prefixID={prefixes.cst_template_ist}
className='rounded-t-none' className='rounded-t-none'
rows={8} rows={8}

View File

@ -55,7 +55,7 @@ function DlgDeleteCst() {
label='Удалить зависимые конституенты' label='Удалить зависимые конституенты'
className='mb-2' className='mb-2'
value={expandOut} value={expandOut}
setValue={value => setExpandOut(value)} onChange={value => setExpandOut(value)}
/> />
{hasInherited ? ( {hasInherited ? (
<p className='text-sm clr-text-red'>Внимание! Выбранные конституенты имеют наследников в ОСС</p> <p className='text-sm clr-text-red'>Внимание! Выбранные конституенты имеют наследников в ОСС</p>

View File

@ -39,7 +39,7 @@ function DlgDeleteOperation() {
label='Сохранить наследованные конституенты' label='Сохранить наследованные конституенты'
titleHtml='Наследованные конституенты <br/>превратятся в дописанные' titleHtml='Наследованные конституенты <br/>превратятся в дописанные'
value={keepConstituents} value={keepConstituents}
setValue={setKeepConstituents} onChange={setKeepConstituents}
disabled={target.result === null} disabled={target.result === null}
/> />
<Checkbox <Checkbox
@ -50,7 +50,7 @@ function DlgDeleteOperation() {
: 'Удалить схему вместе с операцией' : 'Удалить схему вместе с операцией'
} }
value={deleteSchema} value={deleteSchema}
setValue={setDeleteSchema} onChange={setDeleteSchema}
disabled={!target.is_owned || target.result === null} disabled={!target.is_owned || target.result === null}
/> />
</Modal> </Modal>

View File

@ -63,7 +63,7 @@ function DlgEditEditors() {
<SelectUser <SelectUser
filter={id => !selected.includes(id)} filter={id => !selected.includes(id)}
value={undefined} value={undefined}
onSelectValue={onAddEditor} onChange={onAddEditor}
className='w-[25rem]' className='w-[25rem]'
/> />
</div> </div>

View File

@ -19,7 +19,7 @@ function TabArguments({ oss, inputs, target, setInputs }: TabArgumentsProps) {
<div className='cc-fade-in cc-column'> <div className='cc-fade-in cc-column'>
<FlexColumn> <FlexColumn>
<Label text={`Выбор аргументов: [ ${inputs.length} ]`} /> <Label text={`Выбор аргументов: [ ${inputs.length} ]`} />
<PickMultiOperation items={filtered} selected={inputs} setSelected={setInputs} rows={8} /> <PickMultiOperation items={filtered} value={inputs} onChange={setInputs} rows={8} />
</FlexColumn> </FlexColumn>
</div> </div>
); );

View File

@ -29,8 +29,8 @@ function TabSynthesis({
schemas={schemas} schemas={schemas}
prefixID={prefixes.dlg_cst_substitutes_list} prefixID={prefixes.dlg_cst_substitutes_list}
rows={8} rows={8}
substitutions={substitutions} value={substitutions}
setSubstitutions={setSubstitutions} onChange={setSubstitutions}
suggestions={suggestions} suggestions={suggestions}
/> />
<TextArea <TextArea

View File

@ -64,7 +64,7 @@ function TabEntityReference({ initial, schema, onChangeValid, onChangeReference
initialFilter={initial.text} initialFilter={initial.text}
value={selectedCst} value={selectedCst}
data={schema.items} data={schema.items}
onSelectValue={handleSelectConstituenta} onChange={handleSelectConstituenta}
prefixID={prefixes.cst_modal_list} prefixID={prefixes.cst_modal_list}
describeFunc={cst => cst.term_resolved} describeFunc={cst => cst.term_resolved}
matchFunc={(cst, filter) => matchConstituenta(cst, filter, CstMatchMode.TERM)} matchFunc={(cst, filter) => matchConstituenta(cst, filter, CstMatchMode.TERM)}
@ -94,7 +94,7 @@ function TabEntityReference({ initial, schema, onChangeValid, onChangeReference
/> />
</div> </div>
<SelectWordForm selected={selectedGrams} setSelected={setSelectedGrams} /> <SelectWordForm value={selectedGrams} onChange={setSelectedGrams} />
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
<Label text='Словоформа' /> <Label text='Словоформа' />
@ -104,7 +104,7 @@ function TabEntityReference({ initial, schema, onChangeValid, onChangeReference
className='flex-grow' className='flex-grow'
menuPlacement='top' menuPlacement='top'
value={selectedGrams} value={selectedGrams}
onChangeValue={setSelectedGrams} onChange={setSelectedGrams}
/> />
</div> </div>
</div> </div>

View File

@ -13,7 +13,7 @@ import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea'; import TextArea from '@/components/ui/TextArea';
import { Grammeme, IWordForm, IWordFormPlain } from '@/models/language'; import { Grammeme, IWordForm } from '@/models/language';
import { parseGrammemes, wordFormEquals } from '@/models/languageAPI'; import { parseGrammemes, wordFormEquals } from '@/models/languageAPI';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
import { IConstituenta, TermForm } from '@/models/rsform'; import { IConstituenta, TermForm } from '@/models/rsform';
@ -79,11 +79,13 @@ function DlgEditWordForms() {
} }
function handleInflect() { function handleInflect() {
const data: IWordFormPlain = { inflectText(
{
text: term, text: term,
grams: inputGrams.map(gram => gram.value).join(',') grams: inputGrams.map(gram => gram.value).join(',')
}; },
inflectText(data, response => setInputText(response.result)); response => setInputText(response.result)
);
} }
function handleParse() { function handleParse() {
@ -170,7 +172,7 @@ function DlgEditWordForms() {
placeholder='Выберите граммемы' placeholder='Выберите граммемы'
className='min-w-[15rem] h-fit' className='min-w-[15rem] h-fit'
value={inputGrams} value={inputGrams}
onChangeValue={setInputGrams} onChange={setInputGrams}
/> />
</div> </div>

View File

@ -31,31 +31,31 @@ function DlgGraphParams() {
label='Скрыть текст' label='Скрыть текст'
title='Не отображать термины' title='Не отображать термины'
value={params.noText} value={params.noText}
setValue={value => updateParams({ noText: value })} onChange={value => updateParams({ noText: value })}
/> />
<Checkbox <Checkbox
label='Скрыть несвязанные' label='Скрыть несвязанные'
title='Неиспользуемые конституенты' title='Неиспользуемые конституенты'
value={params.noHermits} value={params.noHermits}
setValue={value => updateParams({ noHermits: value })} onChange={value => updateParams({ noHermits: value })}
/> />
<Checkbox <Checkbox
label='Скрыть шаблоны' label='Скрыть шаблоны'
titleHtml='Терм-функции и предикат-функции <br/>с параметризованными аргументами' titleHtml='Терм-функции и предикат-функции <br/>с параметризованными аргументами'
value={params.noTemplates} value={params.noTemplates}
setValue={value => updateParams({ noTemplates: value })} onChange={value => updateParams({ noTemplates: value })}
/> />
<Checkbox <Checkbox
label='Транзитивная редукция' label='Транзитивная редукция'
titleHtml='Удалить связи, образующие <br/>транзитивные пути в графе' titleHtml='Удалить связи, образующие <br/>транзитивные пути в графе'
value={params.noTransitive} value={params.noTransitive}
setValue={value => updateParams({ noTransitive: value })} onChange={value => updateParams({ noTransitive: value })}
/> />
<Checkbox <Checkbox
label='Свернуть порожденные' label='Свернуть порожденные'
title='Не отображать порожденные понятия' title='Не отображать порожденные понятия'
value={params.foldDerived} value={params.foldDerived}
setValue={value => updateParams({ foldDerived: value })} onChange={value => updateParams({ foldDerived: value })}
/> />
</div> </div>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
@ -63,42 +63,42 @@ function DlgGraphParams() {
<Checkbox <Checkbox
label={labelCstType(CstType.BASE)} label={labelCstType(CstType.BASE)}
value={params.allowBase} value={params.allowBase}
setValue={value => updateParams({ allowBase: value })} onChange={value => updateParams({ allowBase: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.STRUCTURED)} label={labelCstType(CstType.STRUCTURED)}
value={params.allowStruct} value={params.allowStruct}
setValue={value => updateParams({ allowStruct: value })} onChange={value => updateParams({ allowStruct: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.TERM)} label={labelCstType(CstType.TERM)}
value={params.allowTerm} value={params.allowTerm}
setValue={value => updateParams({ allowTerm: value })} onChange={value => updateParams({ allowTerm: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.AXIOM)} label={labelCstType(CstType.AXIOM)}
value={params.allowAxiom} value={params.allowAxiom}
setValue={value => updateParams({ allowAxiom: value })} onChange={value => updateParams({ allowAxiom: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.FUNCTION)} label={labelCstType(CstType.FUNCTION)}
value={params.allowFunction} value={params.allowFunction}
setValue={value => updateParams({ allowFunction: value })} onChange={value => updateParams({ allowFunction: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.PREDICATE)} label={labelCstType(CstType.PREDICATE)}
value={params.allowPredicate} value={params.allowPredicate}
setValue={value => updateParams({ allowPredicate: value })} onChange={value => updateParams({ allowPredicate: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.CONSTANT)} label={labelCstType(CstType.CONSTANT)}
value={params.allowConstant} value={params.allowConstant}
setValue={value => updateParams({ allowConstant: value })} onChange={value => updateParams({ allowConstant: value })}
/> />
<Checkbox <Checkbox
label={labelCstType(CstType.THEOREM)} label={labelCstType(CstType.THEOREM)}
value={params.allowTheorem} value={params.allowTheorem}
setValue={value => updateParams({ allowTheorem: value })} onChange={value => updateParams({ allowTheorem: value })}
/> />
</div> </div>
</Modal> </Modal>

View File

@ -21,8 +21,8 @@ function TabConstituents({ itemID, selected, setSelected }: TabConstituentsProps
data={schema.items} data={schema.items}
rows={13} rows={13}
prefixID={prefixes.cst_inline_synth_list} prefixID={prefixes.cst_inline_synth_list}
selected={selected} value={selected}
setSelected={setSelected} onChange={setSelected}
/> />
); );
} }

View File

@ -25,7 +25,7 @@ function TabSource({ selected, receiver, setSelected }: TabSourceProps) {
itemType={LibraryItemType.RSFORM} itemType={LibraryItemType.RSFORM}
rows={14} rows={14}
value={selected} value={selected}
onSelectValue={setSelected} onChange={setSelected}
/> />
<div className='flex items-center gap-6 '> <div className='flex items-center gap-6 '>
<span className='select-none'>Выбрана</span> <span className='select-none'>Выбрана</span>

View File

@ -22,8 +22,8 @@ function TabSubstitutions({ sourceID, receiver, selected, substitutions, setSubs
return ( return (
<PickSubstitutions <PickSubstitutions
substitutions={substitutions} value={substitutions}
setSubstitutions={setSubstitutions} onChange={setSubstitutions}
rows={10} rows={10}
prefixID={prefixes.cst_inline_synth_substitutes} prefixID={prefixes.cst_inline_synth_substitutes}
schemas={schemas} schemas={schemas}

View File

@ -103,7 +103,7 @@ function DlgRelocateConstituents() {
placeholder='Выберите исходную схему' placeholder='Выберите исходную схему'
items={sourceSchemas} items={sourceSchemas}
value={source} value={source}
onSelectValue={handleSelectSource} onChange={handleSelectSource}
/> />
<MiniButton <MiniButton
title='Направление перемещения' title='Направление перемещения'
@ -116,7 +116,7 @@ function DlgRelocateConstituents() {
placeholder='Выберите целевую схему' placeholder='Выберите целевую схему'
items={destinationSchemas} items={destinationSchemas}
value={destination} value={destination}
onSelectValue={handleSelectDestination} onChange={handleSelectDestination}
/> />
</div> </div>
{sourceData.isLoading ? <Loader /> : null} {sourceData.isLoading ? <Loader /> : null}
@ -127,8 +127,8 @@ function DlgRelocateConstituents() {
data={filteredConstituents} data={filteredConstituents}
rows={12} rows={12}
prefixID={prefixes.dlg_cst_constituents_list} prefixID={prefixes.dlg_cst_constituents_list}
selected={selected} value={selected}
setSelected={setSelected} onChange={setSelected}
/> />
) : null} ) : null}
</div> </div>

View File

@ -40,8 +40,8 @@ function DlgSubstituteCst() {
> >
<PickSubstitutions <PickSubstitutions
allowSelfSubstitution allowSelfSubstitution
substitutions={substitutions} value={substitutions}
setSubstitutions={setSubstitutions} onChange={setSubstitutions}
rows={6} rows={6}
prefixID={prefixes.dlg_cst_substitutes_list} prefixID={prefixes.dlg_cst_substitutes_list}
schemas={[schema]} schemas={[schema]}

View File

@ -53,7 +53,7 @@ function DlgUploadRSForm() {
label='Загружать название и комментарий' label='Загружать название и комментарий'
className='py-2' className='py-2'
value={loadMetadata} value={loadMetadata}
setValue={value => setLoadMetadata(value)} onChange={value => setLoadMetadata(value)}
/> />
</Modal> </Modal>
); );

View File

@ -214,21 +214,6 @@ export interface IWordForm {
grams: GramData[]; grams: GramData[];
} }
/**
* Represents wordform data used for backend communication.
*/
export interface IWordFormPlain {
text: string;
grams: string;
}
/**
* Represents lexeme response containing multiple {@link Wordform}s.
*/
export interface ILexemeData {
items: IWordFormPlain[];
}
// ====== Reference resolution ===== // ====== Reference resolution =====
/** /**
@ -270,11 +255,3 @@ export interface IReference {
type: ReferenceType; type: ReferenceType;
data: IEntityReference | ISyntacticReference; data: IEntityReference | ISyntacticReference;
} }
/**
* Represents single resolved reference data.
*/
export interface IResolvedReference extends IReference {
pos_input: ITextPosition;
pos_output: ITextPosition;
}

View File

@ -42,11 +42,6 @@ export interface IOperation {
arguments: OperationID[]; arguments: OperationID[];
} }
/**
* Represents {@link IOperation} data from server.
*/
export interface IOperationData extends Omit<IOperation, 'substitutions' | 'arguments'> {}
/** /**
* Represents {@link IOperation} position. * Represents {@link IOperation} position.
*/ */
@ -115,20 +110,14 @@ export interface IOperationSchemaStats {
count_owned: number; count_owned: number;
} }
/**
* Represents backend data for {@link IOperationSchema}.
*/
export interface IOperationSchemaData extends ILibraryItemData {
items: IOperationData[];
arguments: IArgument[];
substitutions: ICstSubstituteEx[];
}
/** /**
* Represents OperationSchema. * Represents OperationSchema.
*/ */
export interface IOperationSchema extends IOperationSchemaData { export interface IOperationSchema extends ILibraryItemData {
items: IOperation[]; items: IOperation[];
arguments: IArgument[];
substitutions: ICstSubstituteEx[];
graph: Graph; graph: Graph;
schemas: LibraryItemID[]; schemas: LibraryItemID[];
stats: IOperationSchemaStats; stats: IOperationSchemaStats;

View File

@ -2,6 +2,7 @@
* Module: API for OperationSystem. * Module: API for OperationSystem.
*/ */
import { ConstituentaID, CstClass, CstType, IConstituenta, IRSForm } from '@/models/rsform';
import { limits } from '@/utils/constants'; import { limits } from '@/utils/constants';
import { describeSubstitutionError, information } from '@/utils/labels'; import { describeSubstitutionError, information } from '@/utils/labels';
import { TextMatcher } from '@/utils/utils'; import { TextMatcher } from '@/utils/utils';
@ -9,7 +10,6 @@ import { TextMatcher } from '@/utils/utils';
import { Graph } from './Graph'; import { Graph } from './Graph';
import { ILibraryItem, LibraryItemID } from './library'; import { ILibraryItem, LibraryItemID } from './library';
import { ICstSubstitute, IOperation, IOperationSchema, OperationID, SubstitutionErrorType } from './oss'; import { ICstSubstitute, IOperation, IOperationSchema, OperationID, SubstitutionErrorType } from './oss';
import { ConstituentaID, CstClass, CstType, IConstituenta, IRSForm } from './rsform';
import { AliasMapping, ParsingStatus } from './rslang'; import { AliasMapping, ParsingStatus } from './rslang';
import { applyAliasMapping, applyTypificationMapping, extractGlobals, isSetTypification } from './rslangAPI'; import { applyAliasMapping, applyTypificationMapping, extractGlobals, isSetTypification } from './rslangAPI';
@ -49,7 +49,6 @@ export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): I
type CrossMapping = Map<LibraryItemID, AliasMapping>; type CrossMapping = Map<LibraryItemID, AliasMapping>;
// TODO: test validator
/** /**
* Validator for Substitution table. * Validator for Substitution table.
*/ */
@ -459,7 +458,6 @@ export function getRelocateCandidates(
const original = oss.substitutions.find(sub => sub.substitution === parent)?.original; const original = oss.substitutions.find(sub => sub.substitution === parent)?.original;
if (original) { if (original) {
continue; continue;
// TODO: test if original schema is destination schema
} }
} }
unreachableBases.push(cst.id); unreachableBases.push(cst.id);

View File

@ -83,9 +83,9 @@ export interface ITargetCst {
} }
/** /**
* Represents {@link IConstituenta} data from server. * Represents Constituenta.
*/ */
export interface IConstituentaData extends IConstituentaMeta { export interface IConstituenta extends IConstituentaMeta {
parse: { parse: {
status: ParsingStatus; status: ParsingStatus;
valueClass: ValueClass; valueClass: ValueClass;
@ -93,12 +93,7 @@ export interface IConstituentaData extends IConstituentaMeta {
syntaxTree: string; syntaxTree: string;
args: IArgumentInfo[]; args: IArgumentInfo[];
}; };
}
/**
* Represents Constituenta.
*/
export interface IConstituenta extends IConstituentaData {
/** {@link LibraryItemID} of this {@link IConstituenta}. */ /** {@link LibraryItemID} of this {@link IConstituenta}. */
schema: LibraryItemID; schema: LibraryItemID;
@ -172,27 +167,21 @@ export interface IRSFormStats {
/** /**
* Represents inheritance data for {@link IRSForm}. * Represents inheritance data for {@link IRSForm}.
*/ */
export interface IInheritanceData { export interface IInheritanceInfo {
child: ConstituentaID; child: ConstituentaID;
child_source: LibraryItemID; child_source: LibraryItemID;
parent: ConstituentaID; parent: ConstituentaID;
parent_source: LibraryItemID; parent_source: LibraryItemID;
} }
/**
* Represents data for {@link IRSForm} provided by backend.
*/
export interface IRSFormData extends ILibraryItemVersioned {
items: IConstituentaData[];
inheritance: IInheritanceData[];
oss: ILibraryItemReference[];
}
/** /**
* Represents formal explication for set of concepts. * Represents formal explication for set of concepts.
*/ */
export interface IRSForm extends IRSFormData { export interface IRSForm extends ILibraryItemVersioned {
items: IConstituenta[]; items: IConstituenta[];
inheritance: IInheritanceInfo[];
oss: ILibraryItemReference[];
stats: IRSFormStats; stats: IRSFormStats;
graph: Graph; graph: Graph;
cstByAlias: Map<string, IConstituenta>; cstByAlias: Map<string, IConstituenta>;

View File

@ -32,14 +32,6 @@ export interface ICurrentUser {
editor: LibraryItemID[]; editor: LibraryItemID[];
} }
/**
* Represents signup data, used to create new users.
*/
export interface IUserSignupData extends Omit<IUser, 'is_staff' | 'id'> {
password: string;
password2: string;
}
/** /**
* Represents user profile for viewing and editing {@link IUser}. * Represents user profile for viewing and editing {@link IUser}.
*/ */

View File

@ -1,12 +1,14 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useRef } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuthSuspense } from '@/backend/auth/useAuth'; import { useAuthSuspense } from '@/backend/auth/useAuth';
import { ILibraryCreateDTO } from '@/backend/library/api'; import { CreateLibraryItemSchema, ICreateLibraryItemDTO } from '@/backend/library/api';
import { useCreateItem } from '@/backend/library/useCreateItem'; import { useCreateItem } from '@/backend/library/useCreateItem';
import { VisibilityIcon } from '@/components/DomainIcons'; import { VisibilityIcon } from '@/components/DomainIcons';
import { IconDownload } from '@/components/Icons'; import { IconDownload } from '@/components/Icons';
@ -23,38 +25,43 @@ import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea'; import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput'; import TextInput from '@/components/ui/TextInput';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library'; import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';
import { combineLocation, validateLocation } from '@/models/libraryAPI'; import { combineLocation } from '@/models/libraryAPI';
import { useLibrarySearchStore } from '@/stores/librarySearch'; import { useLibrarySearchStore } from '@/stores/librarySearch';
import { EXTEOR_TRS_FILE } from '@/utils/constants'; import { EXTEOR_TRS_FILE } from '@/utils/constants';
function FormCreateItem() { function FormCreateItem() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user } = useAuthSuspense(); const { user } = useAuthSuspense();
const { createItem, isPending, error, reset } = useCreateItem(); const { createItem, isPending, error: serverError, reset: clearServerError } = useCreateItem();
const searchLocation = useLibrarySearchStore(state => state.location); const searchLocation = useLibrarySearchStore(state => state.location);
const setSearchLocation = useLibrarySearchStore(state => state.setLocation); const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const [itemType, setItemType] = useState(LibraryItemType.RSFORM); const {
const [title, setTitle] = useState(''); register,
const [alias, setAlias] = useState(''); handleSubmit,
const [comment, setComment] = useState(''); clearErrors,
const [visible, setVisible] = useState(true); setValue,
const [policy, setPolicy] = useState(AccessPolicy.PUBLIC); control,
formState: { errors }
const [head, setHead] = useState(LocationHead.USER); } = useForm<ICreateLibraryItemDTO>({
const [body, setBody] = useState(''); resolver: zodResolver(CreateLibraryItemSchema),
defaultValues: {
const location = combineLocation(head, body); item_type: LibraryItemType.RSFORM,
const isValid = validateLocation(location); access_policy: AccessPolicy.PUBLIC,
visible: true,
const [fileName, setFileName] = useState(''); read_only: false,
const [file, setFile] = useState<File | undefined>(); location: !!searchLocation ? searchLocation : LocationHead.USER
}
});
const itemType = useWatch({ control, name: 'item_type' });
const file = useWatch({ control, name: 'file' });
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { function resetErrors() {
reset(); clearServerError();
}, [title, alias, reset]); clearErrors();
}
function handleCancel() { function handleCancel() {
if (router.canBack()) { if (router.canBack()) {
@ -64,26 +71,28 @@ function FormCreateItem() {
} }
} }
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault(); if (event.target.files && event.target.files.length > 0) {
if (isPending) { setValue('file', event.target.files[0]);
return; setValue('fileName', event.target.files[0].name);
} else {
setValue('file', undefined);
setValue('fileName', '');
} }
const data: ILibraryCreateDTO = { }
item_type: itemType,
title: title, function handleItemTypeChange(value: LibraryItemType) {
alias: alias, if (value !== LibraryItemType.RSFORM) {
comment: comment, setValue('file', undefined);
read_only: false, setValue('fileName', '');
visible: visible, }
access_policy: policy, setValue('item_type', value);
location: location, }
file: file,
fileName: file?.name function onSubmit(data: ICreateLibraryItemDTO) {
};
setSearchLocation(location);
createItem(data, newItem => { createItem(data, newItem => {
if (itemType == LibraryItemType.RSFORM) { setSearchLocation(data.location);
if (newItem.item_type == LibraryItemType.RSFORM) {
router.push(urls.schema(newItem.id)); router.push(urls.schema(newItem.id));
} else { } else {
router.push(urls.oss(newItem.id)); router.push(urls.oss(newItem.id));
@ -91,43 +100,19 @@ function FormCreateItem() {
}); });
} }
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
if (event.target.files && event.target.files.length > 0) {
setFileName(event.target.files[0].name);
setFile(event.target.files[0]);
} else {
setFileName('');
setFile(undefined);
}
}
const handleSelectLocation = useCallback((newValue: string) => {
setHead(newValue.substring(0, 2) as LocationHead);
setBody(newValue.length > 3 ? newValue.substring(3) : '');
}, []);
useEffect(() => {
if (!searchLocation) {
return;
}
handleSelectLocation(searchLocation);
}, [searchLocation, handleSelectLocation]);
useEffect(() => {
if (itemType !== LibraryItemType.RSFORM) {
setFile(undefined);
setFileName('');
}
}, [itemType]);
return ( return (
<form <form
className={clsx('cc-fade-in cc-column', 'min-w-[30rem] max-w-[30rem] mx-auto', 'px-6 py-3')} className={clsx('cc-fade-in cc-column', 'min-w-[30rem] max-w-[30rem] mx-auto', 'px-6 py-3')}
onSubmit={handleSubmit} onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
> >
<h1 className='select-none'> <h1 className='select-none'>
{itemType == LibraryItemType.RSFORM ? ( {itemType == LibraryItemType.RSFORM ? (
<Overlay position='top-0 right-[0.5rem]'> <Overlay position='top-0 right-[0.5rem]'>
<Controller
control={control}
name='file'
render={() => (
<input <input
id='schema_file' id='schema_file'
ref={inputRef} ref={inputRef}
@ -136,6 +121,8 @@ function FormCreateItem() {
accept={EXTEOR_TRS_FILE} accept={EXTEOR_TRS_FILE}
onChange={handleFileChange} onChange={handleFileChange}
/> />
)}
/>
<MiniButton <MiniButton
title='Загрузить из Экстеор' title='Загрузить из Экстеор'
icon={<IconDownload size='1.25rem' className='icon-primary' />} icon={<IconDownload size='1.25rem' className='icon-primary' />}
@ -146,40 +133,62 @@ function FormCreateItem() {
Создание схемы Создание схемы
</h1> </h1>
{fileName ? <Label className='text-wrap' text={`Загружен файл: ${fileName}`} /> : null} {file ? <Label className='text-wrap' text={`Загружен файл: ${file.name}`} /> : null}
<TextInput <TextInput
id='schema_title' id='schema_title'
required={!file} {...register('title')}
label='Полное название' label='Полное название'
placeholder={file && 'Загрузить из файла'} placeholder={file && 'Загрузить из файла'}
value={title} error={errors.title}
onChange={event => setTitle(event.target.value)}
/> />
<div className='flex justify-between gap-3'> <div className='flex justify-between gap-3'>
<TextInput <TextInput
id='schema_alias' id='schema_alias'
required={!file} {...register('alias')}
label='Сокращение' label='Сокращение'
placeholder={file && 'Загрузить из файла'} placeholder={file && 'Загрузить из файла'}
className='w-[16rem]' className='w-[16rem]'
value={alias} error={errors.alias}
onChange={event => setAlias(event.target.value)}
/> />
<div className='flex flex-col items-center gap-2'> <div className='flex flex-col items-center gap-2'>
<Label text='Тип схемы' className='self-center select-none' /> <Label text='Тип схемы' className='self-center select-none' />
<SelectItemType value={itemType} onChange={setItemType} /> <Controller
control={control}
name='item_type'
render={({ field }) => (
<SelectItemType
value={field.value} //
onChange={handleItemTypeChange}
/>
)}
/>
</div> </div>
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>
<Label text='Доступ' className='self-center select-none' /> <Label text='Доступ' className='self-center select-none' />
<div className='ml-auto cc-icons'> <div className='ml-auto cc-icons'>
<SelectAccessPolicy value={policy} onChange={setPolicy} /> <Controller
control={control} //
name='access_policy'
render={({ field }) => (
<SelectAccessPolicy
value={field.value} //
onChange={field.onChange}
/>
)}
/>
<Controller
control={control}
name='visible'
render={({ field }) => (
<MiniButton <MiniButton
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'} title={field.value ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={visible} />} icon={<VisibilityIcon value={field.value} />}
onClick={() => setVisible(prev => !prev)} onClick={() => field.onChange(!field.value)}
/>
)}
/> />
</div> </div>
</div> </div>
@ -187,36 +196,58 @@ function FormCreateItem() {
<TextArea <TextArea
id='schema_comment' id='schema_comment'
{...register('comment')}
label='Описание' label='Описание'
placeholder={file && 'Загрузить из файла'} placeholder={file && 'Загрузить из файла'}
value={comment} error={errors.comment}
onChange={event => setComment(event.target.value)}
/> />
<div className='flex justify-between gap-3 flex-grow'> <div className='flex justify-between gap-3 flex-grow'>
<div className='flex flex-col gap-2 min-w-[7rem] h-min'> <div className='flex flex-col gap-2 min-w-[7rem] h-min'>
<Label text='Корень' /> <Label text='Корень' />
<Controller
control={control} //
name='location'
render={({ field }) => (
<SelectLocationHead <SelectLocationHead
value={head} value={field.value.substring(0, 2) as LocationHead}
onChange={setHead} onChange={newValue => field.onChange(combineLocation(newValue, field.value.substring(3)))}
excluded={!user?.is_staff ? [LocationHead.LIBRARY] : []} excluded={!user.is_staff ? [LocationHead.LIBRARY] : []}
/>
)}
/> />
</div> </div>
<SelectLocationContext value={location} onChange={handleSelectLocation} /> <Controller
control={control} //
name='location'
render={({ field }) => (
<SelectLocationContext
value={field.value} //
onChange={field.onChange}
/>
)}
/>
<Controller
control={control} //
name='location'
render={({ field }) => (
<TextArea <TextArea
id='dlg_cst_body' id='dlg_cst_body'
label='Путь' label='Путь'
rows={4} rows={4}
value={body} value={field.value.substring(3)}
onChange={event => setBody(event.target.value)} onChange={event => field.onChange(combineLocation(field.value.substring(0, 2), event.target.value))}
error={errors.location}
/>
)}
/> />
</div> </div>
<div className='flex justify-around gap-6 py-3'> <div className='flex justify-around gap-6 py-3'>
<SubmitButton text='Создать схему' loading={isPending} className='min-w-[10rem]' disabled={!isValid} /> <SubmitButton text='Создать схему' loading={isPending} className='min-w-[10rem]' />
<Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Отмена' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div> </div>
{error ? <InfoError error={error} /> : null} {serverError ? <InfoError error={serverError} /> : null}
</form> </form>
); );
} }

View File

@ -127,7 +127,7 @@ function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
placeholder='Выберите владельца' placeholder='Выберите владельца'
className='min-w-[15rem] text-sm mx-1 mb-1' className='min-w-[15rem] text-sm mx-1 mb-1'
value={filterUser} value={filterUser}
onSelectValue={setFilterUser} onChange={setFilterUser}
/> />
</Dropdown> </Dropdown>
</div> </div>

View File

@ -34,7 +34,7 @@ function ViewSideLocation({ isVisible, onRenameLocation }: ViewSideLocationProps
const toggleSubfolders = useLibrarySearchStore(state => state.toggleSubfolders); const toggleSubfolders = useLibrarySearchStore(state => state.toggleSubfolders);
const canRename = (() => { const canRename = (() => {
if (location.length <= 3 || isAnonymous || !user) { if (location.length <= 3 || isAnonymous) {
return false; return false;
} }
if (user.is_staff) { if (user.is_staff) {

View File

@ -1,11 +1,13 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import axios from 'axios'; import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { IUserLoginDTO, UserLoginSchema } from '@/backend/auth/api';
import { useAuthSuspense } from '@/backend/auth/useAuth'; import { useAuthSuspense } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin'; import { useLogin } from '@/backend/auth/useLogin';
import ExpectedAnonymous from '@/components/ExpectedAnonymous'; import ExpectedAnonymous from '@/components/ExpectedAnonymous';
@ -19,22 +21,25 @@ import { resources } from '@/utils/constants';
function LoginPage() { function LoginPage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const query = useQueryStrings(); const query = useQueryStrings();
const userQuery = query.get('username'); const initialName = query.get('username') ?? '';
const {
register,
handleSubmit,
clearErrors,
resetField,
formState: { errors }
} = useForm({
resolver: zodResolver(UserLoginSchema),
defaultValues: { username: initialName, password: '' }
});
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const { login, isPending, error, reset } = useLogin(); const { login, isPending, error: serverError, reset: clearServerError } = useLogin();
const [username, setUsername] = useState(userQuery || ''); function onSubmit(data: IUserLoginDTO) {
const [password, setPassword] = useState(''); login(data, () => {
resetField('password');
useEffect(() => {
reset();
}, [username, password, reset]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isPending) {
login(username, password, () => {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
@ -42,47 +47,49 @@ function LoginPage() {
} }
}); });
} }
function resetErrors() {
clearServerError();
clearErrors();
} }
if (!isAnonymous) { if (!isAnonymous) {
return <ExpectedAnonymous />; return <ExpectedAnonymous />;
} }
return ( return (
<form className={clsx('cc-column cc-fade-in', 'w-[24rem] mx-auto', 'pt-12 pb-6 px-6')} onSubmit={handleSubmit}> <form
className={clsx('cc-column cc-fade-in', 'w-[24rem] mx-auto', 'pt-12 pb-6 px-6')}
onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
>
<img alt='Концепт Портал' src={resources.logo} className='max-h-[2.5rem] min-w-[2.5rem] mb-3' /> <img alt='Концепт Портал' src={resources.logo} className='max-h-[2.5rem] min-w-[2.5rem] mb-3' />
<TextInput <TextInput
id='username' id='username'
label='Логин или email'
autoComplete='username' autoComplete='username'
label='Логин или email'
{...register('username')}
autoFocus autoFocus
required
allowEnter allowEnter
spellCheck={false} spellCheck={false}
value={username} defaultValue={initialName}
onChange={event => setUsername(event.target.value)} error={errors.username}
/> />
<TextInput <TextInput
id='password' id='password'
{...register('password')}
type='password' type='password'
label='Пароль'
autoComplete='current-password' autoComplete='current-password'
required label='Пароль'
allowEnter allowEnter
value={password} error={errors.password}
onChange={event => setPassword(event.target.value)}
/> />
<SubmitButton <SubmitButton text='Войти' className='self-center w-[12rem] mt-3' loading={isPending} />
text='Войти'
className='self-center w-[12rem] mt-3'
loading={isPending}
disabled={!username || !password}
/>
<div className='flex flex-col text-sm'> <div className='flex flex-col text-sm'>
<TextURL text='Восстановить пароль...' href='/restore-password' /> <TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' /> <TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />
</div> </div>
{error ? <ProcessError error={error} /> : null} {serverError ? <ServerError error={serverError} /> : null}
</form> </form>
); );
} }
@ -90,7 +97,7 @@ function LoginPage() {
export default LoginPage; export default LoginPage;
// ====== Internals ========= // ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement { function ServerError({ error }: { error: ErrorData }): React.ReactElement | null {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) { if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return ( return (
<div className='text-sm select-text text-warn-600'> <div className='text-sm select-text text-warn-600'>

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