F: Rework createLibraryItem form

This commit is contained in:
Ivan 2025-02-04 23:33:35 +03:00
parent e0abbe6534
commit 336794ec6c
10 changed files with 203 additions and 137 deletions

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,4 +1,5 @@
import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod';
import { axiosDelete, axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration';
@ -13,9 +14,10 @@ import {
LibraryItemType,
VersionID
} from '@/models/library';
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'> {}
/**
@ -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, IRSFormDTO>({
cloneItem: (data: IRCloneLibraryItemDTO) =>
axiosPost<IRCloneLibraryItemDTO, IRSFormDTO>({
endpoint: `/api/library/${data.id}/clone`,
request: {
data: data,

View File

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

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

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) => {
menu.hide();
if (newValue !== value) {
onChange(newValue);
}
},
[menu, value, onChange]
);
function handleChange(newValue: LibraryItemType) {
menu.hide();
if (newValue !== value) {
onChange(newValue);
}
}
return (
<div ref={menu.ref} {...restProps}>

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

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

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,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 (
<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]'>
<input
id='schema_file'
ref={inputRef}
type='file'
style={{ display: 'none' }}
accept={EXTEOR_TRS_FILE}
onChange={handleFileChange}
<Controller
control={control}
name='file'
render={() => (
<input
id='schema_file'
ref={inputRef}
type='file'
style={{ display: 'none' }}
accept={EXTEOR_TRS_FILE}
onChange={handleFileChange}
/>
)}
/>
<MiniButton
title='Загрузить из Экстеор'
@ -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} />
<MiniButton
title={visible ? 'Библиотека: отображать' : 'Библиотека: скрывать'}
icon={<VisibilityIcon value={visible} />}
onClick={() => setVisible(prev => !prev)}
<Controller
control={control} //
name='access_policy'
render={({ field }) => (
<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>
@ -187,32 +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='Корень' />
<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>
<SelectLocationContext value={location} onChange={handleSelectLocation} />
<TextArea
id='dlg_cst_body'
label='Путь'
rows={4}
value={body}
onChange={event => setBody(event.target.value)}
<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={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

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