Окно создания восстанавливается после F5 вместе с черновиком
usePersistedOpen хранит в localStorage признак «модалка создания открыта» для типов заявок, доступа к заявкам, маршрутов и шагов маршрута (ключ шага — по id маршрута). После F5 окно открывается снова с полями из черновика; любое закрытие сбрасывает признак, а сам черновик остаётся до сохранения или «Очистить». Редактирование не восстанавливается. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ 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 { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
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';
|
||||||
@@ -44,6 +45,7 @@ const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail';
|
|||||||
|
|
||||||
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
|
/** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */
|
||||||
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess';
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess';
|
||||||
|
|
||||||
interface IFormState {
|
interface IFormState {
|
||||||
requestTypeId: string;
|
requestTypeId: string;
|
||||||
@@ -62,7 +64,7 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
const access = useRequestTypeAccess(api);
|
const access = useRequestTypeAccess(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
const draft = useFormDraft<IFormState>(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM);
|
||||||
const { requestTypeId, emails } = draft.value;
|
const { requestTypeId, emails } = draft.value;
|
||||||
const setEmails = (update: (current: string[]) => string[]): void =>
|
const setEmails = (update: (current: string[]) => string[]): void =>
|
||||||
@@ -109,7 +111,7 @@ 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 => {
|
||||||
setShowForm(true);
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitForm = (): void => {
|
const submitForm = (): void => {
|
||||||
@@ -140,7 +142,7 @@ 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();
|
draft.reset();
|
||||||
setShowForm(false);
|
createOpen.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEmails(() => failedEmails);
|
setEmails(() => failedEmails);
|
||||||
@@ -240,13 +242,13 @@ export const RequestTypeAccessScreen: React.FC<IRequestTypeAccessScreenProps> =
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{createOpen.isOpen && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={texts.requestTypeAccess.createTitle}
|
title={texts.requestTypeAccess.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={createOpen.close}
|
||||||
onReset={draft.reset}
|
onReset={draft.reset}
|
||||||
canReset={draft.isDirty}
|
canReset={draft.isDirty}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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 { useFormDraft } from '../../hooks/useFormDraft';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import {
|
import {
|
||||||
ActionsCell,
|
ActionsCell,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -60,6 +61,7 @@ const EMPTY_FORM: IFormState = {
|
|||||||
|
|
||||||
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
/** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */
|
||||||
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
|
const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType';
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType';
|
||||||
|
|
||||||
function toFormState(item: RequestType): IFormState {
|
function toFormState(item: RequestType): IFormState {
|
||||||
return {
|
return {
|
||||||
@@ -94,7 +96,13 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
const list = useRequestTypes(api);
|
const list = useRequestTypes(api);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
const [editing, setEditing] = React.useState<RequestType | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
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;
|
||||||
@@ -112,12 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: RequestType): void => {
|
const openEdit = (item: RequestType): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
setEditForm(toFormState(item));
|
setEditForm(toFormState(item));
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,7 +165,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
submit.run(() =>
|
submit.run(() =>
|
||||||
request.then(() => {
|
request.then(() => {
|
||||||
if (!editing) draft.reset();
|
if (!editing) draft.reset();
|
||||||
setShowForm(false);
|
closeForm();
|
||||||
list.reload();
|
list.reload();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -204,13 +214,13 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<FormModal
|
<FormModal
|
||||||
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
title={editing ? texts.requestTypes.editTitle : texts.requestTypes.createTitle}
|
||||||
error={submit.error}
|
error={submit.error}
|
||||||
isSaving={submit.isBusy}
|
isSaving={submit.isBusy}
|
||||||
onSubmit={submitForm}
|
onSubmit={submitForm}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
onReset={editing ? undefined : draft.reset}
|
onReset={editing ? undefined : draft.reset}
|
||||||
canReset={draft.isDirty}
|
canReset={draft.isDirty}
|
||||||
isDirty={isEditDirty}
|
isDirty={isEditDirty}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u
|
|||||||
import { reportError } from '../../utils/errors';
|
import { reportError } from '../../utils/errors';
|
||||||
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
import { useRouteSteps } from '../../hooks/useRouteSteps';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen),
|
||||||
|
// ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
const deleteTarget = useDeleteTarget<TemplateRouteStep>(
|
||||||
item => api.templateRouteSteps.remove(route.id, item.id),
|
item => api.templateRouteSteps.remove(route.id, item.id),
|
||||||
@@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRouteStep): void => {
|
const openEdit = (item: TemplateRouteStep): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<RouteStepFormModal
|
<RouteStepFormModal
|
||||||
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
// Undefined, если у маршрута компания не задана (запасной маршрут,
|
||||||
// применяется к любой компании инициатора) — тогда подсказка
|
// применяется к любой компании инициатора) — тогда подсказка
|
||||||
@@ -293,7 +303,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
|
|||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
|
|||||||
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
|
||||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
|
||||||
|
import { usePersistedOpen } from '../../hooks/usePersistedOpen';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { replaceRoute } from '../../routing';
|
import { replaceRoute } from '../../routing';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,8 @@ export interface IRoutesScreenProps {
|
|||||||
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
|
||||||
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
|
||||||
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
|
||||||
|
/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */
|
||||||
|
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route';
|
||||||
|
|
||||||
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
||||||
api,
|
api,
|
||||||
@@ -69,7 +72,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
|
||||||
|
|
||||||
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
|
||||||
|
// showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen).
|
||||||
const [showForm, setShowForm] = React.useState(false);
|
const [showForm, setShowForm] = React.useState(false);
|
||||||
|
const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY);
|
||||||
|
const closeForm = (): void => {
|
||||||
|
setShowForm(false);
|
||||||
|
createOpen.close();
|
||||||
|
};
|
||||||
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
|
||||||
|
|
||||||
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
|
||||||
@@ -83,11 +92,13 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setShowForm(true);
|
setShowForm(false);
|
||||||
|
createOpen.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: TemplateRoute): void => {
|
const openEdit = (item: TemplateRoute): void => {
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
|
createOpen.close();
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +170,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showForm && (
|
{(showForm || createOpen.isOpen) && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
api={api}
|
api={api}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
@@ -169,7 +180,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
|
|||||||
departments={portalDictionaries.departments}
|
departments={portalDictionaries.departments}
|
||||||
dictionaryLoading={portalDictionaries.isLoading}
|
dictionaryLoading={portalDictionaries.isLoading}
|
||||||
onSaved={routes.reload}
|
onSaved={routes.reload}
|
||||||
onClose={() => setShowForm(false)}
|
onClose={closeForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ describe('coerceStored', () => {
|
|||||||
expect(coerceStored<number[]>([], [1, 2])).toEqual([1, 2]);
|
expect(coerceStored<number[]>([], [1, 2])).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('для boolean-флага отбрасывает значение другого типа', () => {
|
||||||
|
expect(coerceStored(false, true)).toBe(true);
|
||||||
|
expect(coerceStored(false, 'true')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('при initial === undefined принимает сохранённое как есть', () => {
|
it('при initial === undefined принимает сохранённое как есть', () => {
|
||||||
expect(coerceStored<string | undefined>(undefined, 'a')).toBe('a');
|
expect(coerceStored<string | undefined>(undefined, 'a')).toBe('a');
|
||||||
expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' });
|
expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' });
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useLocalStorageState } from './useLocalStorageState';
|
||||||
|
|
||||||
|
export interface IPersistedOpen {
|
||||||
|
isOpen: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Признак «модалка создания открыта», переживающий F5: после перезагрузки окно
|
||||||
|
* открывается снова, а поля подставляются из черновика (см. useFormDraft).
|
||||||
|
* close() сбрасывает только признак — черновик остаётся до сохранения или «Очистить».
|
||||||
|
*/
|
||||||
|
export function usePersistedOpen(key: string): IPersistedOpen {
|
||||||
|
const [isOpen, setIsOpen] = useLocalStorageState<boolean>(key, false);
|
||||||
|
return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user