Предупреждение при выходе из формы редактирования с несохранёнными правками
Tests / frontend-test (pull_request) Successful in 1m31s
Tests / backend-test (pull_request) Successful in 1m35s

FormModal получил isDirty: если в форме редактирования существующей
записи что-то изменили, крестик, «Отмена» и клик мимо окна сначала
показывают диалог «Закрыть без сохранения?». Подключено к редактированию
типа заявки, маршрута, шага маршрута и согласующего в заявке. Формы
создания не затронуты — их черновик и так сохраняется.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-16 10:02:40 +05:00
parent b94e6e60f5
commit 872686e855
7 changed files with 84 additions and 33 deletions
@@ -74,6 +74,11 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase())); const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase())); const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
const isDirty =
email !== initialEmail ||
slaHours !== slaHoursToInput(initialSlaHours) ||
askOverdueReason !== boolToTriState(initialAskOverdueReason);
const handleSubmit = (): void => { const handleSubmit = (): void => {
const trimmedEmail = email.trim(); const trimmedEmail = email.trim();
if (!trimmedEmail) { if (!trimmedEmail) {
@@ -95,7 +100,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
}; };
return ( return (
<FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose}> <FormModal title={title} error={submit.error} isSaving={submit.isBusy} onSubmit={handleSubmit} onClose={onClose} isDirty={isDirty}>
<FormGrid> <FormGrid>
<Field label={texts.manageRequests.approverLabel} required full> <Field label={texts.manageRequests.approverLabel} required full>
<SearchableSelect <SearchableSelect
@@ -61,6 +61,18 @@ const EMPTY_FORM: IFormState = {
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */ /** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType'; const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
function toFormState(item: RequestType): IFormState {
return {
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
};
}
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда // siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined, // (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде. // внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
@@ -86,6 +98,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
const [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM); const [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM);
const form = editing ? editForm : draft.value; const form = editing ? editForm : draft.value;
const isEditDirty = Boolean(editing) && JSON.stringify(editForm) !== JSON.stringify(toFormState(editing as RequestType));
const setForm = editing ? setEditForm : draft.setValue; const setForm = editing ? setEditForm : draft.setValue;
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl); const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
@@ -104,15 +117,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const openEdit = (item: RequestType): void => { const openEdit = (item: RequestType): void => {
setEditing(item); setEditing(item);
setEditForm({ setEditForm(toFormState(item));
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
});
setShowForm(true); setShowForm(true);
}; };
@@ -208,6 +213,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
onClose={() => setShowForm(false)} onClose={() => setShowForm(false)}
onReset={editing ? undefined : draft.reset} onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty} canReset={draft.isDirty}
isDirty={isEditDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.requestTypes.nameLabel} required full> <Field label={texts.requestTypes.nameLabel} required full>
@@ -116,6 +116,7 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onClose={onClose} onClose={onClose}
onReset={editing ? undefined : draft.reset} onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty} canReset={draft.isDirty}
isDirty={Boolean(editing) && draft.isDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.routes.requestTypeLabel} required full> <Field label={texts.routes.requestTypeLabel} required full>
@@ -423,6 +423,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onClose={onClose} onClose={onClose}
onReset={editing ? undefined : draft.reset} onReset={editing ? undefined : draft.reset}
canReset={draft.isDirty} canReset={draft.isDirty}
isDirty={Boolean(editing) && draft.isDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.routeSteps.orderNoLabel} required> <Field label={texts.routeSteps.orderNoLabel} required>
@@ -8,6 +8,8 @@ export interface IConfirmDialogProps {
title: string; title: string;
text: string; text: string;
confirmLabel: string; confirmLabel: string;
/** Подпись кнопки отказа — по умолчанию «Отмена». */
cancelLabel?: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
busy?: boolean; busy?: boolean;
@@ -17,6 +19,7 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title, title,
text, text,
confirmLabel, confirmLabel,
cancelLabel = texts.common.cancel,
onConfirm, onConfirm,
onCancel, onCancel,
busy busy
@@ -26,7 +29,7 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
<div className={styles.title}>{title}</div> <div className={styles.title}>{title}</div>
<p className={styles.text}>{text}</p> <p className={styles.text}>{text}</p>
<div className={styles.narrowFooter}> <div className={styles.narrowFooter}>
<Button variant="secondary" onClick={onCancel} disabled={busy}>{texts.common.cancel}</Button> <Button variant="secondary" onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
<Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button> <Button variant="danger" icon="trash" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Alert } from './Alert'; import { Alert } from './Alert';
import { Button } from './Button'; import { Button } from './Button';
import { ConfirmDialog } from './ConfirmDialog';
import { Modal } from './Modal'; import { Modal } from './Modal';
import styles from './Modal.module.scss'; import styles from './Modal.module.scss';
import { texts } from '../../texts'; import { texts } from '../../texts';
@@ -22,6 +23,12 @@ export interface IFormModalProps {
*/ */
onReset?: () => void; onReset?: () => void;
canReset?: boolean; canReset?: boolean;
/**
* В форме есть несохранённые правки существующей записи: выход (крестик,
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
* не нужен — их черновик сохраняется сам (см. useFormDraft).
*/
isDirty?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -39,11 +46,22 @@ export const FormModal: React.FC<IFormModalProps> = ({
onClose, onClose,
onReset, onReset,
canReset, canReset,
isDirty,
children children
}) => ( }) => {
const [confirmingClose, setConfirmingClose] = React.useState(false);
// Во время сохранения не закрываем и не спрашиваем: запрос уже ушёл.
const requestClose = (): void => {
if (isSaving) return;
if (isDirty) setConfirmingClose(true);
else onClose();
};
return (
<>
<Modal <Modal
title={title} title={title}
onClose={onClose} onClose={requestClose}
footer={ footer={
<> <>
{onReset && canReset && ( {onReset && canReset && (
@@ -51,7 +69,7 @@ export const FormModal: React.FC<IFormModalProps> = ({
<Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button> <Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button>
</span> </span>
)} )}
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button> <Button variant="secondary" onClick={requestClose} disabled={isSaving}>{texts.common.cancel}</Button>
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}> <Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? savingLabel : submitLabel} {isSaving ? savingLabel : submitLabel}
</Button> </Button>
@@ -61,4 +79,16 @@ export const FormModal: React.FC<IFormModalProps> = ({
{error && <Alert kind="err">{error}</Alert>} {error && <Alert kind="err">{error}</Alert>}
{children} {children}
</Modal> </Modal>
); {confirmingClose && (
<ConfirmDialog
title={texts.common.discardTitle}
text={texts.common.discardText}
confirmLabel={texts.common.discardConfirm}
cancelLabel={texts.common.discardKeepEditing}
onConfirm={onClose}
onCancel={() => setConfirmingClose(false)}
/>
)}
</>
);
};
@@ -78,6 +78,11 @@ export const texts = {
close: 'Закрыть', close: 'Закрыть',
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */ /** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
resetDraft: 'Очистить', resetDraft: 'Очистить',
/** Предупреждение при выходе из формы редактирования с несохранёнными правками. */
discardTitle: 'Закрыть без сохранения?',
discardText: 'Внесённые изменения не сохранятся.',
discardConfirm: 'Закрыть без сохранения',
discardKeepEditing: 'Продолжить редактирование',
loading: 'Загрузка...', loading: 'Загрузка...',
yes: 'Да', yes: 'Да',
no: 'Нет', no: 'Нет',