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