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-tooltip
- react-zoom-pan-pinch
- react-hook-form
- reactflow
- js-file-download
- use-debounce
- qrcode.react
- html-to-image
- zustand
- zod
- @hookform/resolvers
- @tanstack/react-table
- @tanstack/react-query
- @tanstack/react-query-devtools

View File

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

View File

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

View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^3.10.0",
"@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-query-devtools": "^5.64.2",
@ -23,6 +24,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0",
"react-intl": "^7.1.5",
"react-router": "^7.1.3",
@ -33,6 +35,7 @@
"react-zoom-pan-pinch": "^3.6.1",
"reactflow": "^11.11.4",
"use-debounce": "^10.0.4",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
@ -1698,6 +1701,15 @@
"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": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -9101,6 +9113,22 @@
"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": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",
@ -10932,7 +10960,6 @@
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

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

View File

@ -1,5 +1,4 @@
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Outlet } from 'react-router';
import ConceptToaster from '@/app/ConceptToaster';
@ -10,22 +9,10 @@ import ModalLoader from '@/components/ui/ModalLoader';
import { useAppLayoutStore, useMainHeight, useViewportHeight } from '@/stores/appLayout';
import { globals } from '@/utils/constants';
import ErrorFallback from './ErrorFallback';
import { GlobalDialogs } from './GlobalDialogs';
import { GlobalTooltips } from './GlobalTooltips';
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() {
const mainHeight = useMainHeight();
const viewportHeight = useViewportHeight();
@ -35,7 +22,6 @@ function ApplicationLayout() {
const noFooter = useAppLayoutStore(state => state.noFooter);
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={logError} onReset={resetState}>
<NavigationState>
<div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'>
<ConceptToaster
@ -69,7 +55,6 @@ function ApplicationLayout() {
</div>
</div>
</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 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 (
<div className='flex flex-col gap-3 my-3 items-center antialiased' role='alert'>
<h1 className='my-2'>Что-то пошло не так!</h1>
<Button onClick={resetErrorBoundary} text='Попробовать еще раз' />
<Button onClick={resetErrorBoundary} text='Вернуться на главную' />
<InfoError error={error as Error} />
</div>
);

View File

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

View File

@ -24,18 +24,6 @@ export const routes = {
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.
*/
@ -58,12 +46,24 @@ export const urls = {
schema: (id: number | string, version?: number | string) =>
`/rsforms/${id}` + (version !== undefined ? `?v=${version}` : ''),
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 activeStr = active !== undefined ? `&active=${active}` : '';
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}`;
}
};

View File

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

View File

@ -1,25 +1,46 @@
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 { ICurrentUser } from '@/models/user';
import { information } from '@/utils/labels';
import { errors, information } from '@/utils/labels';
/**
* Represents login data, used to authenticate users.
*/
export interface IUserLoginDTO {
username: string;
password: string;
}
export const UserLoginSchema = z.object({
username: z.string().nonempty(errors.requiredField),
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.
*/
export interface IChangePasswordDTO {
old_password: string;
new_password: string;
}
export const ChangePasswordSchema = z
.object({
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.
@ -69,7 +90,7 @@ export const authApi = {
request: { data: data }
}),
changePassword: (data: IChangePasswordDTO) =>
axiosPost({
axiosPatch({
endpoint: '/users/api/change-password',
request: {
data: data,

View File

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

View File

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

View File

@ -21,6 +21,7 @@ export const useResetPassword = () => {
onSuccess?: () => void
) => resetMutation.mutate(data, { onSuccess }),
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 { ILexemeData, IWordFormPlain } from '@/models/language';
/**
* Represents API result for text output.
@ -8,11 +7,26 @@ export interface ITextResult {
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 = {
baseKey: 'cctext',
inflectText: (data: IWordFormPlain) =>
axiosPost<IWordFormPlain, ITextResult>({
inflectText: (data: IWordFormDTO) =>
axiosPost<IWordFormDTO, ITextResult>({
endpoint: '/api/cctext/inflect',
request: { data: data }
}),
@ -22,7 +36,7 @@ export const cctextApi = {
request: { data: data }
}),
generateLexeme: (data: { text: string }) =>
axiosPost<{ text: string }, ILexemeData>({
axiosPost<{ text: string }, ILexemeResponse>({
endpoint: '/api/cctext/generate-lexeme',
request: { data: data }
})

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
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 = () => {
const client = useQueryClient();
@ -14,8 +14,8 @@ export const useCloneItem = () => {
});
return {
cloneItem: (
data: IRSFormCloneDTO, //
onSuccess?: DataCallback<IRSFormData>
data: IRCloneLibraryItemDTO, //
onSuccess?: DataCallback<IRSFormDTO>
) => mutation.mutate(data, { onSuccess })
};
};

View File

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

View File

@ -2,7 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ossApi } from '@/backend/oss/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';
@ -12,13 +13,16 @@ export const useDeleteItem = () => {
mutationKey: [libraryApi.baseKey, 'delete-item'],
mutationFn: libraryApi.deleteItem,
onSuccess: (_, variables) => {
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
prev?.filter(item => item.id !== variables)
);
return Promise.allSettled([
client.invalidateQueries({ queryKey: libraryApi.libraryListKey }).catch(console.error);
setTimeout(
() =>
void Promise.allSettled([
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 {

View File

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

View File

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

View File

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

View File

@ -1,11 +1,10 @@
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 { 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 = () => {
const client = useQueryClient();
@ -17,11 +16,11 @@ export const useUpdateItem = () => {
client.setQueryData(libraryApi.libraryListKey, (prev: ILibraryItem[] | undefined) =>
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 }
);
if (data.item_type === LibraryItemType.RSFORM) {
const schema: IRSFormData | undefined = client.getQueryData(itemKey);
const schema: IRSFormDTO | undefined = client.getQueryData(itemKey);
if (schema) {
return Promise.allSettled(
schema.oss.map(item =>
@ -33,6 +32,6 @@ export const useUpdateItem = () => {
}
});
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 { rsformsApi } from '@/backend/rsform/api';
import { IRSFormDTO, rsformsApi } from '@/backend/rsform/api';
import { LibraryItemID, VersionID } from '@/models/library';
import { IRSFormData } from '@/models/rsform';
import { libraryApi } from './api';
@ -14,7 +13,7 @@ export const useVersionDelete = () => {
onSuccess: (_, variables) => {
client.setQueryData(
rsformsApi.getRSFormQueryOptions({ itemID: variables.itemID }).queryKey,
(prev: IRSFormData | undefined) =>
(prev: IRSFormDTO | undefined) =>
!prev
? undefined
: {

View File

@ -1,8 +1,7 @@
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 { IRSFormData } from '@/models/rsform';
import { libraryApi } from './api';
@ -14,7 +13,7 @@ export const useVersionUpdate = () => {
onSuccess: data => {
client.setQueryData(
rsformsApi.getRSFormQueryOptions({ itemID: data.item }).queryKey,
(prev: IRSFormData | undefined) =>
(prev: IRSFormDTO | undefined) =>
!prev
? undefined
: {

View File

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

View File

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

View File

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

View File

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

View File

@ -2,27 +2,29 @@
* Module: RSForm data loading and processing.
*/
import { Graph } from './Graph';
import { LibraryItemID } from './library';
import { ConstituentaID, CstType, IConstituenta, IRSForm, IRSFormData, IRSFormStats } from './rsform';
import { inferClass, inferStatus, inferTemplate, isBaseSet, isFunctional } from './rsformAPI';
import { ParsingStatus, ValueClass } from './rslang';
import { extractGlobals, isSimpleExpression, splitTemplateDefinition } from './rslangAPI';
import { Graph } from '@/models/Graph';
import { LibraryItemID } from '@/models/library';
import { ConstituentaID, CstType, IConstituenta, IRSForm, IRSFormStats } from '@/models/rsform';
import { inferClass, inferStatus, inferTemplate, isBaseSet, isFunctional } from '@/models/rsformAPI';
import { ParsingStatus, ValueClass } from '@/models/rslang';
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
* 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.
*/
export class RSFormLoader {
private schema: IRSFormData;
private schema: IRSFormDTO;
private graph: Graph = new Graph();
private cstByAlias = new Map<string, IConstituenta>();
private cstByID = new Map<ConstituentaID, IConstituenta>();
constructor(input: IRSFormData) {
constructor(input: IRSFormDTO) {
this.schema = input;
}
@ -159,7 +161,8 @@ export class RSFormLoader {
} else if (sources.size !== 1) {
return false;
} 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;
}
};

View File

@ -2,20 +2,42 @@ import { queryOptions } from '@tanstack/react-query';
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
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 {
ConstituentaID,
CstType,
IConstituentaList,
IConstituentaMeta,
IRSFormData,
IInheritanceInfo,
ITargetCst,
TermForm
} from '@/models/rsform';
import { IExpressionParse } from '@/models/rslang';
import { IArgumentInfo, IExpressionParse, ParsingStatus, ValueClass } from '@/models/rslang';
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.
*/
@ -46,7 +68,7 @@ export interface ICstCreateDTO {
*/
export interface ICstCreatedResponse {
new_cst: IConstituentaMeta;
schema: IRSFormData;
schema: IRSFormDTO;
}
/**
@ -85,7 +107,7 @@ export interface ICstMoveDTO {
*/
export interface IProduceStructureResponse {
cst_list: ConstituentaID[];
schema: IRSFormData;
schema: IRSFormDTO;
}
/**
@ -117,7 +139,7 @@ export const rsformsApi = {
queryFn: meta =>
!itemID
? undefined
: axiosGet<IRSFormData>({
: axiosGet<IRSFormDTO>({
endpoint: version ? `/api/library/${itemID}/versions/${version}` : `/api/rsforms/${itemID}/details`,
options: { signal: meta.signal }
})
@ -130,7 +152,7 @@ export const rsformsApi = {
options: { responseType: 'blob' }
}),
upload: (data: IRSFormUploadDTO) =>
axiosPatch<IRSFormUploadDTO, IRSFormData>({
axiosPatch<IRSFormUploadDTO, IRSFormDTO>({
endpoint: `/api/rsforms/${data.itemID}/load-trs`,
request: {
data: data,
@ -160,7 +182,7 @@ export const rsformsApi = {
}
}),
cstDelete: ({ itemID, data }: { itemID: LibraryItemID; data: IConstituentaList }) =>
axiosDelete<IConstituentaList, IRSFormData>({
axiosDelete<IConstituentaList, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/delete-multiple-cst`,
request: {
data: data,
@ -176,7 +198,7 @@ export const rsformsApi = {
}
}),
cstSubstitute: ({ itemID, data }: { itemID: LibraryItemID; data: ICstSubstitutions }) =>
axiosPatch<ICstSubstitutions, IRSFormData>({
axiosPatch<ICstSubstitutions, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/substitute`,
request: {
data: data,
@ -184,7 +206,7 @@ export const rsformsApi = {
}
}),
cstMove: ({ itemID, data }: { itemID: LibraryItemID; data: ICstMoveDTO }) =>
axiosPatch<ICstMoveDTO, IRSFormData>({
axiosPatch<ICstMoveDTO, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/move-cst`,
request: { data: data }
}),
@ -198,7 +220,7 @@ export const rsformsApi = {
}
}),
inlineSynthesis: ({ itemID, data }: { itemID: LibraryItemID; data: IInlineSynthesisDTO }) =>
axiosPost<IInlineSynthesisDTO, IRSFormData>({
axiosPost<IInlineSynthesisDTO, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/inline-synthesis`,
request: {
data: data,
@ -206,12 +228,12 @@ export const rsformsApi = {
}
}),
restoreOrder: ({ itemID }: { itemID: LibraryItemID }) =>
axiosPatch<undefined, IRSFormData>({
axiosPatch<undefined, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/restore-order`,
request: { successMessage: information.reorderComplete }
}),
resetAliases: ({ itemID }: { itemID: LibraryItemID }) =>
axiosPatch<undefined, IRSFormData>({
axiosPatch<undefined, IRSFormDTO>({
endpoint: `/api/rsforms/${itemID}/reset-aliases`,
request: { successMessage: information.reindexComplete }
}),

View File

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

View File

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

View File

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

View File

@ -1,14 +1,45 @@
import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration';
import { IUser, IUserInfo, IUserProfile, IUserSignupData } from '@/models/user';
import { information } from '@/utils/labels';
import { IUserInfo, IUserProfile } from '@/models/user';
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.
*/
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 = {
baseKey: 'users',
@ -33,8 +64,8 @@ export const usersApi = {
})
}),
signup: (data: IUserSignupData) =>
axiosPost<IUserSignupData, IUserProfile>({
signup: (data: IUserSignupDTO) =>
axiosPost<IUserSignupDTO, IUserProfile>({
endpoint: '/users/api/signup',
request: {
data: data,

View File

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

View File

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

View File

@ -1,5 +1,6 @@
// =========== Module contains interfaces for common UI elements. ==========
import React from 'react';
import { FieldError } from 'react-hook-form';
export namespace CProps {
/**
@ -35,6 +36,13 @@ export namespace CProps {
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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,10 +10,10 @@ import { matchConstituenta } from '@/models/rsformAPI';
import { describeConstituenta, describeConstituentaTerm } from '@/utils/labels';
interface SelectConstituentaProps extends CProps.Styling {
items?: IConstituenta[];
value?: IConstituenta;
onSelectValue: (newValue?: IConstituenta) => void;
onChange: (newValue?: IConstituenta) => void;
items?: IConstituenta[];
placeholder?: string;
noBorder?: boolean;
}
@ -22,7 +22,7 @@ function SelectConstituenta({
className,
items,
value,
onSelectValue,
onChange,
placeholder = 'Выберите конституенту',
...restProps
}: SelectConstituentaProps) {
@ -42,7 +42,7 @@ function SelectConstituenta({
className={clsx('text-ellipsis', className)}
options={options}
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
filterOption={filter}
placeholder={placeholder}

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import { matchLibraryItem } from '@/models/libraryAPI';
interface SelectLibraryItemProps extends CProps.Styling {
items?: ILibraryItem[];
value?: ILibraryItem;
onSelectValue: (newValue?: ILibraryItem) => void;
onChange: (newValue?: ILibraryItem) => void;
placeholder?: string;
noBorder?: boolean;
@ -20,7 +20,7 @@ function SelectLibraryItem({
className,
items,
value,
onSelectValue,
onChange,
placeholder = 'Выберите схему',
...restProps
}: SelectLibraryItemProps) {
@ -40,7 +40,7 @@ function SelectLibraryItem({
className={clsx('text-ellipsis', className)}
options={options}
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
filterOption={filter}
placeholder={placeholder}

View File

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

View File

@ -1,7 +1,6 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import { IconFolderTree } from '@/components/Icons';
import { CProps } from '@/components/props';
@ -14,10 +13,9 @@ import SelectLocation from './SelectLocation';
interface SelectLocationContextProps extends CProps.Styling {
value: string;
onChange: (newValue: string) => void;
title?: string;
stretchTop?: boolean;
onChange: (newValue: string) => void;
}
function SelectLocationContext({
@ -29,15 +27,12 @@ function SelectLocationContext({
}: SelectLocationContextProps) {
const menu = useDropdown();
const handleClick = useCallback(
(event: CProps.EventMouse, newValue: string) => {
function handleClick(event: CProps.EventMouse, newValue: string) {
event.preventDefault();
event.stopPropagation();
menu.hide();
onChange(newValue);
},
[menu, onChange]
);
}
return (
<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';
import clsx from 'clsx';
import { useCallback } from 'react';
import { LocationIcon } from '@/components/DomainIcons';
import { CProps } from '@/components/props';
@ -22,13 +21,10 @@ interface SelectLocationHeadProps extends CProps.Styling {
function SelectLocationHead({ value, excluded = [], onChange, className, ...restProps }: SelectLocationHeadProps) {
const menu = useDropdown();
const handleChange = useCallback(
(newValue: LocationHead) => {
function handleChange(newValue: LocationHead) {
menu.hide();
onChange(newValue);
},
[menu, onChange]
);
}
return (
<div ref={menu.ref} className={clsx('h-full text-right', className)} {...restProps}>

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import { matchOperation } from '@/models/ossAPI';
interface SelectOperationProps extends CProps.Styling {
items?: IOperation[];
value?: IOperation;
onSelectValue: (newValue?: IOperation) => void;
onChange: (newValue?: IOperation) => void;
placeholder?: string;
noBorder?: boolean;
@ -20,7 +20,7 @@ function SelectOperation({
className,
items,
value,
onSelectValue,
onChange,
placeholder = 'Выберите операцию',
...restProps
}: SelectOperationProps) {
@ -40,7 +40,7 @@ function SelectOperation({
className={clsx('text-ellipsis', className)}
options={options}
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
filterOption={filter}
placeholder={placeholder}

View File

@ -11,7 +11,7 @@ import { matchUser } from '@/models/userAPI';
interface SelectUserProps extends CProps.Styling {
value?: UserID;
onSelectValue: (newValue: UserID) => void;
onChange: (newValue: UserID) => void;
filter?: (userID: UserID) => boolean;
placeholder?: string;
@ -22,7 +22,7 @@ function SelectUser({
className,
filter,
value,
onSelectValue,
onChange,
placeholder = 'Выберите пользователя',
...restProps
}: SelectUserProps) {
@ -46,7 +46,7 @@ function SelectUser({
options={options}
value={value ? { value: value, label: getUserLabel(value) } : null}
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
filterOption={filterLabel}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@ function SelectAll<TData>({ table, resetLastSelected }: SelectAllProps<TData>) {
value={
!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);
}
return <Checkbox tabIndex={-1} value={row.getIsSelected()} setValue={handleChange} />;
return <Checkbox tabIndex={-1} value={row.getIsSelected()} onChange={handleChange} />;
}
export default SelectRow;

View File

@ -3,7 +3,7 @@ import clsx from 'clsx';
import Checkbox, { CheckboxProps } from './Checkbox';
/** Animated {@link Checkbox} inside a {@link Dropdown} item. */
function DropdownCheckbox({ setValue, disabled, ...restProps }: CheckboxProps) {
function DropdownCheckbox({ onChange: setValue, disabled, ...restProps }: CheckboxProps) {
return (
<div
className={clsx(
@ -13,7 +13,7 @@ function DropdownCheckbox({ setValue, disabled, ...restProps }: CheckboxProps) {
!!setValue && !disabled && 'clr-hover'
)}
>
<Checkbox tabIndex={-1} disabled={disabled} setValue={setValue} {...restProps} />
<Checkbox tabIndex={-1} disabled={disabled} onChange={setValue} {...restProps} />
</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;
/** Callback to be called when the value changes. */
onChangeValue: (newItem: ItemType) => void;
onChange: (newItem: ItemType) => void;
/** Callback providing the parent of the item. */
getParent: (item: ItemType) => ItemType;
@ -40,7 +40,7 @@ function SelectTree<ItemType>({
getParent,
getLabel,
getDescription,
onChangeValue,
onChange,
prefix,
...restProps
}: SelectTreeProps<ItemType>) {
@ -75,7 +75,7 @@ function SelectTree<ItemType>({
function handleSetValue(event: CProps.EventMouse, target: ItemType) {
event.preventDefault();
event.stopPropagation();
onChangeValue(target);
onChange(target);
}
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@ function TabSynthesisOperation({
<FlexColumn>
<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>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -214,21 +214,6 @@ export interface IWordForm {
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 =====
/**
@ -270,11 +255,3 @@ export interface IReference {
type: ReferenceType;
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[];
}
/**
* Represents {@link IOperation} data from server.
*/
export interface IOperationData extends Omit<IOperation, 'substitutions' | 'arguments'> {}
/**
* Represents {@link IOperation} position.
*/
@ -115,20 +110,14 @@ export interface IOperationSchemaStats {
count_owned: number;
}
/**
* Represents backend data for {@link IOperationSchema}.
*/
export interface IOperationSchemaData extends ILibraryItemData {
items: IOperationData[];
arguments: IArgument[];
substitutions: ICstSubstituteEx[];
}
/**
* Represents OperationSchema.
*/
export interface IOperationSchema extends IOperationSchemaData {
export interface IOperationSchema extends ILibraryItemData {
items: IOperation[];
arguments: IArgument[];
substitutions: ICstSubstituteEx[];
graph: Graph;
schemas: LibraryItemID[];
stats: IOperationSchemaStats;

View File

@ -2,6 +2,7 @@
* Module: API for OperationSystem.
*/
import { ConstituentaID, CstClass, CstType, IConstituenta, IRSForm } from '@/models/rsform';
import { limits } from '@/utils/constants';
import { describeSubstitutionError, information } from '@/utils/labels';
import { TextMatcher } from '@/utils/utils';
@ -9,7 +10,6 @@ import { TextMatcher } from '@/utils/utils';
import { Graph } from './Graph';
import { ILibraryItem, LibraryItemID } from './library';
import { ICstSubstitute, IOperation, IOperationSchema, OperationID, SubstitutionErrorType } from './oss';
import { ConstituentaID, CstClass, CstType, IConstituenta, IRSForm } from './rsform';
import { AliasMapping, ParsingStatus } from './rslang';
import { applyAliasMapping, applyTypificationMapping, extractGlobals, isSetTypification } from './rslangAPI';
@ -49,7 +49,6 @@ export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): I
type CrossMapping = Map<LibraryItemID, AliasMapping>;
// TODO: test validator
/**
* Validator for Substitution table.
*/
@ -459,7 +458,6 @@ export function getRelocateCandidates(
const original = oss.substitutions.find(sub => sub.substitution === parent)?.original;
if (original) {
continue;
// TODO: test if original schema is destination schema
}
}
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: {
status: ParsingStatus;
valueClass: ValueClass;
@ -93,12 +93,7 @@ export interface IConstituentaData extends IConstituentaMeta {
syntaxTree: string;
args: IArgumentInfo[];
};
}
/**
* Represents Constituenta.
*/
export interface IConstituenta extends IConstituentaData {
/** {@link LibraryItemID} of this {@link IConstituenta}. */
schema: LibraryItemID;
@ -172,27 +167,21 @@ export interface IRSFormStats {
/**
* Represents inheritance data for {@link IRSForm}.
*/
export interface IInheritanceData {
export interface IInheritanceInfo {
child: ConstituentaID;
child_source: LibraryItemID;
parent: ConstituentaID;
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.
*/
export interface IRSForm extends IRSFormData {
export interface IRSForm extends ILibraryItemVersioned {
items: IConstituenta[];
inheritance: IInheritanceInfo[];
oss: ILibraryItemReference[];
stats: IRSFormStats;
graph: Graph;
cstByAlias: Map<string, IConstituenta>;

View File

@ -32,14 +32,6 @@ export interface ICurrentUser {
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}.
*/

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import axios from 'axios';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls';
import { IUserLoginDTO, UserLoginSchema } from '@/backend/auth/api';
import { useAuthSuspense } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin';
import ExpectedAnonymous from '@/components/ExpectedAnonymous';
@ -19,22 +21,25 @@ import { resources } from '@/utils/constants';
function LoginPage() {
const router = useConceptNavigation();
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 { login, isPending, error, reset } = useLogin();
const { login, isPending, error: serverError, reset: clearServerError } = useLogin();
const [username, setUsername] = useState(userQuery || '');
const [password, setPassword] = useState('');
useEffect(() => {
reset();
}, [username, password, reset]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isPending) {
login(username, password, () => {
function onSubmit(data: IUserLoginDTO) {
login(data, () => {
resetField('password');
if (router.canBack()) {
router.back();
} else {
@ -42,47 +47,49 @@ function LoginPage() {
}
});
}
function resetErrors() {
clearServerError();
clearErrors();
}
if (!isAnonymous) {
return <ExpectedAnonymous />;
}
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' />
<TextInput
id='username'
label='Логин или email'
autoComplete='username'
label='Логин или email'
{...register('username')}
autoFocus
required
allowEnter
spellCheck={false}
value={username}
onChange={event => setUsername(event.target.value)}
defaultValue={initialName}
error={errors.username}
/>
<TextInput
id='password'
{...register('password')}
type='password'
label='Пароль'
autoComplete='current-password'
required
label='Пароль'
allowEnter
value={password}
onChange={event => setPassword(event.target.value)}
error={errors.password}
/>
<SubmitButton
text='Войти'
className='self-center w-[12rem] mt-3'
loading={isPending}
disabled={!username || !password}
/>
<SubmitButton text='Войти' className='self-center w-[12rem] mt-3' loading={isPending} />
<div className='flex flex-col text-sm'>
<TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />
</div>
{error ? <ProcessError error={error} /> : null}
{serverError ? <ServerError error={serverError} /> : null}
</form>
);
}
@ -90,7 +97,7 @@ function LoginPage() {
export default LoginPage;
// ====== 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) {
return (
<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