F: Rework createLibraryItem form

This commit is contained in:
Ivan 2025-02-04 23:34:02 +03:00
parent c76af6d224
commit 515f398fda
10 changed files with 203 additions and 137 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { IOperationSchemaDTO, ossApi } from '@/backend/oss/api';
import { ILibraryItem, LibraryItemType } from '@/models/library'; import { ILibraryItem, LibraryItemType } from '@/models/library';
import { IRSFormDTO } from '../rsform/api'; import { IRSFormDTO } from '../rsform/api';
import { ILibraryUpdateDTO, libraryApi } from './api'; import { IUpdateLibraryItemDTO, libraryApi } from './api';
export const useUpdateItem = () => { export const useUpdateItem = () => {
const client = useQueryClient(); const client = useQueryClient();
@ -32,6 +32,6 @@ export const useUpdateItem = () => {
} }
}); });
return { return {
updateItem: (data: ILibraryUpdateDTO) => mutation.mutate(data) updateItem: (data: IUpdateLibraryItemDTO) => mutation.mutate(data)
}; };
}; };

View File

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

View File

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

View File

@ -50,11 +50,12 @@ function TextInput({
<input <input
id={id} id={id}
className={clsx( className={clsx(
'min-w-0 py-2 mt-2', 'min-w-0 py-2',
'leading-tight truncate hover:text-clip', 'leading-tight truncate hover:text-clip',
{ {
'px-3': !noBorder || !disabled, 'px-3': !noBorder || !disabled,
'flex-grow max-w-full': dense, 'flex-grow max-w-full': dense,
'mt-2': !dense,
'border': !noBorder, 'border': !noBorder,
'clr-outline': !noOutline 'clr-outline': !noOutline
}, },

View File

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

View File

@ -991,7 +991,8 @@ export const errors = {
emailField: 'Введите корректный адрес электронной почты', emailField: 'Введите корректный адрес электронной почты',
rulesNotAccepted: 'Примите условия пользования Порталом', rulesNotAccepted: 'Примите условия пользования Порталом',
privacyNotAccepted: 'Примите политику обработки персональных данных', privacyNotAccepted: 'Примите политику обработки персональных данных',
loginFormat: 'Имя пользователя должно содержать только буквы и цифры' loginFormat: 'Имя пользователя должно содержать только буквы и цифры',
invalidLocation: 'Некорректный формат пути'
}; };
/** /**