Персист модалок: черновики, восстановление после F5, предупреждение при выходе #30
+6
-1
@@ -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: 'Нет',
|
||||||
|
|||||||
Reference in New Issue
Block a user