Черновики форм создания сохраняются в localStorage
Tests / backend-test (pull_request) Successful in 1m35s
Tests / frontend-test (pull_request) Successful in 1m31s

Модалки по жёлтым кнопкам «Добавить» (типы заявок, доступ к заявкам,
маршруты, шаги маршрута) теряли введённое при случайном клике по
подложке. Теперь черновик живёт в localStorage (useFormDraft) и
переживает закрытие окна, «Отмену» и F5. Сбрасывается после успешного
сохранения или кнопкой «Очистить» в подвале модалки. Формы
редактирования не персистятся — всегда открываются с данными сервера.

Попутно убраны Sass-предупреждения mixed-decls в Card.module.scss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-15 17:35:26 +05:00
parent 0e6a7064d2
commit 3d970bf461
10 changed files with 150 additions and 23 deletions
@@ -9,6 +9,7 @@ import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { useFormDraft } from '../../hooks/useFormDraft';
import { toEmployeeOption } from '../../utils/employee';
import { replaceRoute } from '../../routing';
import { RequestTypeAccess } from '../../models';
@@ -41,6 +42,16 @@ export interface IRequestTypeAccessScreenProps {
const ACCESS_FILTER_TYPE_STORAGE_KEY = 'approvalAdmin.accessFilterRequestTypeId';
const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
interface IFormState {
requestTypeId: string;
emails: string[];
}
const EMPTY_FORM: IFormState = { requestTypeId: '', emails: [] };
export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> = ({
api,
dictionary,
@@ -52,8 +63,10 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
const employees = usePortalEmployees(dictionary);
const [showForm, setShowForm] = React.useState(false);
const [requestTypeId, setRequestTypeId] = React.useState('');
const [emails, setEmails] = React.useState<string[]>([]);
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
const { requestTypeId, emails } = draft.value;
const setEmails = (update: (current: string[]) => string[]): void =>
draft.setValue(f => ({ ...f, emails: update(f.emails) }));
const submit = useAsyncSubmit();
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, '');
@@ -96,8 +109,6 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
: item.sharePointLists.map(l => l.title ?? l.listId).join(', ');
const openCreate = (): void => {
setRequestTypeId('');
setEmails([]);
setShowForm(true);
};
@@ -128,10 +139,11 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
if (failedEmails.length < emails.length) access.reload();
if (failures.length === 0) {
draft.reset();
setShowForm(false);
return;
}
setEmails(failedEmails);
setEmails(() => failedEmails);
throw new Error(texts.requestTypeAccess.createFailed(failures));
});
};
@@ -235,6 +247,8 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={() => setShowForm(false)}
onReset={draft.reset}
canReset={draft.isDirty}
>
<FormGrid>
<Field label={texts.requestTypeAccess.requestTypeLabel} required full>
@@ -242,7 +256,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
options={requestTypes.items.map(rt => ({ value: String(rt.id), label: rt.name }))}
placeholder={texts.requestTypeAccess.requestTypePlaceholder}
value={requestTypeId}
onChange={e => setRequestTypeId(e.target.value)}
onChange={e => draft.setValue(f => ({ ...f, requestTypeId: e.target.value }))}
/>
</Field>
<Field label={texts.requestTypeAccess.emailLabel} required full>
@@ -19,6 +19,7 @@ import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
import { useSharePointListTitle } from '../../hooks/useSharePointListTitle';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useFormDraft } from '../../hooks/useFormDraft';
import {
ActionsCell,
Alert,
@@ -57,6 +58,9 @@ const EMPTY_FORM: IFormState = {
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
};
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
@@ -79,7 +83,10 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const [editing, setEditing] = React.useState<RequestType | undefined>();
const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
const [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM);
const form = editing ? editForm : draft.value;
const setForm = editing ? setEditForm : draft.setValue;
const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const siteTitle = useSharePointSiteTitle(sharePoint, form.siteUrl);
@@ -92,13 +99,12 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const openCreate = (): void => {
setEditing(undefined);
setForm(EMPTY_FORM);
setShowForm(true);
};
const openEdit = (item: RequestType): void => {
setEditing(item);
setForm({
setEditForm({
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
@@ -143,6 +149,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
submit.run(() =>
request.then(() => {
if (!editing) draft.reset();
setShowForm(false);
list.reload();
})
@@ -199,6 +206,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={() => setShowForm(false)}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
>
<FormGrid>
<Field label={texts.requestTypes.nameLabel} required full>
@@ -2,6 +2,7 @@ import * as React from 'react';
import { Company, Department, RequestType, TemplateRoute } from '../../models';
import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useFormDraft } from '../../hooks/useFormDraft';
import { ApprovalRouteApi } from '../../services/api';
import { Checkbox, Field, FormGrid, FormModal, SearchableSelect, Select } from '../ui';
@@ -27,6 +28,9 @@ interface IFormState {
requiresControl: boolean;
}
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.route';
function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: string): IFormState {
if (!editing) {
return {
@@ -58,7 +62,10 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onSaved,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, initialRequestTypeId));
// Редактирование не персистим — форма всегда открывается с данными маршрута с сервера.
const draft = useFormDraft<IFormState>(editing ? undefined : CREATE_DRAFT_STORAGE_KEY, toFormState(editing, initialRequestTypeId));
const form = draft.value;
const setForm = draft.setValue;
const submit = useAsyncSubmit();
// Отдел принадлежит компании, поэтому список сужаем под выбранную:
@@ -93,6 +100,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.run(() =>
request.then(() => {
draft.reset();
onSaved();
onClose();
})
@@ -106,6 +114,8 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
>
<FormGrid>
<Field label={texts.routes.requestTypeLabel} required full>
@@ -24,6 +24,7 @@ import {
import { detectExecutorKind } from '../../utils/stepExecutor';
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useFormDraft } from '../../hooks/useFormDraft';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
@@ -119,6 +120,9 @@ const IS_MANAGER_VALUE_OPTIONS = [
{ value: 'false', label: texts.common.no }
];
/** Черновик формы создания шага — свой у каждого маршрута, переживает закрытие модалки (см. useFormDraft). */
const createDraftStorageKey = (routeId: number): string => `approvalAdmin.draft.routeStep.${routeId}`;
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) {
return {
@@ -170,7 +174,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onSaved,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
// Редактирование не персистим — форма всегда открывается с данными шага с сервера.
const draft = useFormDraft<IFormState>(editing ? undefined : createDraftStorageKey(route.id), toFormState(editing, nextOrderNo));
const form = draft.value;
const setForm = draft.setValue;
const submit = useAsyncSubmit();
const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
@@ -400,6 +407,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.run(() =>
request.then(step => {
draft.reset();
onSaved(step);
onClose();
})
@@ -413,6 +421,8 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
isSaving={submit.isBusy}
onSubmit={submitForm}
onClose={onClose}
onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty}
>
<FormGrid>
<Field label={texts.routeSteps.orderNoLabel} required>
@@ -16,12 +16,13 @@
gap: 16px;
box-shadow: $sh-1;
cursor: pointer;
@include card-hover-gold;
border: 1px solid $gray-200;
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
// Миксин с вложенным &:hover — после всех объявлений, иначе Sass ругается mixed-decls.
@include card-hover-gold;
&::before {
content: '';
@@ -2,6 +2,7 @@ import * as React from 'react';
import { Alert } from './Alert';
import { Button } from './Button';
import { Modal } from './Modal';
import styles from './Modal.module.scss';
import { texts } from '../../texts';
export interface IFormModalProps {
@@ -15,6 +16,12 @@ export interface IFormModalProps {
savingLabel?: string;
onSubmit: () => void;
onClose: () => void;
/**
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
*/
onReset?: () => void;
canReset?: boolean;
children: React.ReactNode;
}
@@ -30,6 +37,8 @@ export const FormModal: React.FC<IFormModalProps> = ({
savingLabel = texts.common.saving,
onSubmit,
onClose,
onReset,
canReset,
children
}) => (
<Modal
@@ -37,6 +46,11 @@ export const FormModal: React.FC<IFormModalProps> = ({
onClose={onClose}
footer={
<>
{onReset && canReset && (
<span className={styles.footerStart}>
<Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button>
</span>
)}
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? savingLabel : submitLabel}
@@ -63,4 +63,7 @@
padding: 16px 26px 22px;
}
/** Кнопка в подвале, прижатая влево, подальше от «Отмена/Сохранить» (см. «Очистить» в FormModal). */
.footerStart { margin-right: auto; }
.narrowFooter { justify-content: center; padding: 0; margin-top: 24px; }
@@ -0,0 +1,58 @@
import * as React from 'react';
import { readLocalStorage } from './useLocalStorageState';
export interface IFormDraft<T> {
value: T;
setValue: React.Dispatch<React.SetStateAction<T>>;
/** Форма отличается от initial — есть что сбрасывать. */
isDirty: boolean;
/** Вернуть initial и забыть черновик (кнопка «Очистить», успешное сохранение). */
reset: () => void;
}
function removeDraft(key: string): void {
try {
window.localStorage.removeItem(key);
} catch {
// приватный режим — черновика и не было
}
}
/**
* Состояние формы создания, которое переживает закрытие модалки (случайный клик
* по подложке, «Отмена», F5): черновик лежит в localStorage под key, пока его
* не сбросят явно — reset() после успешного сохранения или по кнопке «Очистить».
*
* key === undefined — обычный useState без персиста: так форма редактирования
* существующей записи всегда открывается с актуальными данными с сервера, а не
* с устаревшей правкой.
*/
export function useFormDraft<T>(key: string | undefined, initial: T): IFormDraft<T> {
const [value, setValue] = React.useState<T>(() => (key ? readLocalStorage(key, initial) : initial));
// initial у вызывающих — литерал, пересоздаётся каждый рендер; сравниваем по содержимому.
const initialJson = JSON.stringify(initial);
const isDirty = JSON.stringify(value) !== initialJson;
React.useEffect(() => {
if (!key) return;
if (!isDirty) {
removeDraft(key);
return;
}
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// приватный режим / переполненная квота — не критично, просто не персистим
}
}, [key, value, isDirty]);
// Ключ удаляем синхронно, а не через эффект: после успешного сохранения модалка
// тут же размонтируется, и эффект на новое значение уже не отработает.
const reset = (): void => {
if (key) removeDraft(key);
setValue(JSON.parse(initialJson) as T);
};
return { value, setValue, isDirty, reset };
}
@@ -4,6 +4,22 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Значение из localStorage под key, мелко смёрженное с initial (см. useLocalStorageState),
* или initial, если записи нет / она битая / localStorage недоступен.
*/
export function readLocalStorage<T>(key: string, initial: T): T {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return initial;
const parsed = JSON.parse(raw) as T;
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
} catch {
return initial;
}
}
/**
* Как React.useState, но значение переживает перезагрузку страницы — хранится
* в localStorage под key. Падение доступа к localStorage (приватный режим,
@@ -15,17 +31,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
*/
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = React.useState<T>(() => {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return initial;
const parsed = JSON.parse(raw) as T;
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
} catch {
return initial;
}
});
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
React.useEffect(() => {
try {
@@ -76,6 +76,8 @@ export const texts = {
remove: 'Удалить',
edit: 'Редактировать',
close: 'Закрыть',
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
resetDraft: 'Очистить',
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',