From 08dc484d035189d1f99eccfb3485194aefc520de Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 16 Oct 2026 10:30:42 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BA=D0=BD=D0=BE=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20F5=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=B5=20=D1=81=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usePersistedOpen хранит в localStorage признак «модалка создания открыта» для типов заявок, доступа к заявкам, маршрутов и шагов маршрута (ключ шага — по id маршрута). После F5 окно открывается снова с полями из черновика; любое закрытие сбрасывает признак, а сам черновик остаётся до сохранения или «Очистить». Редактирование не восстанавливается. Co-Authored-By: Claude Sonnet 5.5 --- .../screens/RequestTypeAccessScreen.tsx | 12 +++++++----- .../components/screens/RequestTypesScreen.tsx | 18 ++++++++++++++---- .../components/screens/RouteStepsScreen.tsx | 16 +++++++++++++--- .../components/screens/RoutesScreen.tsx | 17 ++++++++++++++--- .../hooks/useLocalStorageState.test.ts | 5 +++++ .../approvalAdmin/hooks/usePersistedOpen.ts | 17 +++++++++++++++++ 6 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx index b6c13f4..82f907a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx @@ -10,6 +10,7 @@ import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useFormDraft } from '../../hooks/useFormDraft'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { toEmployeeOption } from '../../utils/employee'; import { replaceRoute } from '../../routing'; import { RequestTypeAccess } from '../../models'; @@ -44,6 +45,7 @@ const ACCESS_FILTER_EMAIL_STORAGE_KEY = 'approvalAdmin.accessFilterEmail'; /** Черновик формы выдачи доступа — переживает закрытие модалки, см. useFormDraft. */ const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestTypeAccess'; +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestTypeAccess'; interface IFormState { requestTypeId: string; @@ -62,7 +64,7 @@ export const RequestTypeAccessScreen: React.FC = const access = useRequestTypeAccess(api); const employees = usePortalEmployees(dictionary); - const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY); const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); const { requestTypeId, emails } = draft.value; const setEmails = (update: (current: string[]) => string[]): void => @@ -109,7 +111,7 @@ export const RequestTypeAccessScreen: React.FC = : item.sharePointLists.map(l => l.title ?? l.listId).join(', '); const openCreate = (): void => { - setShowForm(true); + createOpen.open(); }; const submitForm = (): void => { @@ -140,7 +142,7 @@ export const RequestTypeAccessScreen: React.FC = if (failedEmails.length < emails.length) access.reload(); if (failures.length === 0) { draft.reset(); - setShowForm(false); + createOpen.close(); return; } setEmails(() => failedEmails); @@ -240,13 +242,13 @@ export const RequestTypeAccessScreen: React.FC = } /> - {showForm && ( + {createOpen.isOpen && ( setShowForm(false)} + onClose={createOpen.close} onReset={draft.reset} canReset={draft.isDirty} > diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index 92f1b81..101bd9a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -20,6 +20,7 @@ import { useSharePointListTitle } from '../../hooks/useSharePointListTitle'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useFormDraft } from '../../hooks/useFormDraft'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { ActionsCell, Alert, @@ -60,6 +61,7 @@ const EMPTY_FORM: IFormState = { /** Черновик формы создания — переживает закрытие модалки, см. useFormDraft. */ const CREATE_DRAFT_STORAGE_KEY = 'approvalAdmin.draft.requestType'; +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.requestType'; function toFormState(item: RequestType): IFormState { return { @@ -94,7 +96,13 @@ export const RequestTypesScreen: React.FC = ({ api, sh const list = useRequestTypes(api); const [editing, setEditing] = React.useState(); + // showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen). const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(CREATE_OPEN_STORAGE_KEY); + const closeForm = (): void => { + setShowForm(false); + createOpen.close(); + }; const draft = useFormDraft(CREATE_DRAFT_STORAGE_KEY, EMPTY_FORM); const [editForm, setEditForm] = React.useState(EMPTY_FORM); const form = editing ? editForm : draft.value; @@ -112,12 +120,14 @@ export const RequestTypesScreen: React.FC = ({ api, sh const openCreate = (): void => { setEditing(undefined); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: RequestType): void => { setEditing(item); setEditForm(toFormState(item)); + createOpen.close(); setShowForm(true); }; @@ -155,7 +165,7 @@ export const RequestTypesScreen: React.FC = ({ api, sh submit.run(() => request.then(() => { if (!editing) draft.reset(); - setShowForm(false); + closeForm(); list.reload(); }) ); @@ -204,13 +214,13 @@ export const RequestTypesScreen: React.FC = ({ api, sh emptyMessage={list.isLoading ? texts.common.loading : texts.requestTypes.emptyList} /> - {showForm && ( + {(showForm || createOpen.isOpen) && ( setShowForm(false)} + onClose={closeForm} onReset={editing ? undefined : draft.reset} canReset={draft.isDirty} isDirty={isEditDirty} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx index 7846f19..ed3a260 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx @@ -13,6 +13,7 @@ import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../u import { reportError } from '../../utils/errors'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { @@ -53,7 +54,14 @@ export const RouteStepsScreen: React.FC = ({ api, route, const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`; const [editing, setEditing] = React.useState(); + // showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen), + // ключ свой у каждого маршрута — как и у черновика (см. RouteStepFormModal). const [showForm, setShowForm] = React.useState(false); + const createOpen = usePersistedOpen(`approvalAdmin.draftOpen.routeStep.${route.id}`); + const closeForm = (): void => { + setShowForm(false); + createOpen.close(); + }; const deleteTarget = useDeleteTarget( item => api.templateRouteSteps.remove(route.id, item.id), @@ -71,11 +79,13 @@ export const RouteStepsScreen: React.FC = ({ api, route, const openCreate = (): void => { setEditing(undefined); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: TemplateRouteStep): void => { setEditing(item); + createOpen.close(); setShowForm(true); }; @@ -278,7 +288,7 @@ export const RouteStepsScreen: React.FC = ({ api, route, )} - {showForm && ( + {(showForm || createOpen.isOpen) && ( = ({ api, route, employees={employees.items} employeesLoading={employees.isLoading} onSaved={handleSaved} - onClose={() => setShowForm(false)} + onClose={closeForm} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx index 8f612f8..5015bc3 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx @@ -11,6 +11,7 @@ import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useTemplateRoutes } from '../../hooks/useTemplateRoutes'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; +import { usePersistedOpen } from '../../hooks/usePersistedOpen'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { replaceRoute } from '../../routing'; import { @@ -39,6 +40,8 @@ export interface IRoutesScreenProps { // Фильтр по типу заявки переживает перезагрузку: администратор обычно работает // с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно. const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter'; +/** Окно создания маршрута переживает F5 — поля подставляются из черновика (см. RouteFormModal). */ +const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.route'; export const RoutesScreen: React.FC = ({ api, @@ -69,7 +72,13 @@ export const RoutesScreen: React.FC = ({ const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined); const [editing, setEditing] = React.useState(); + // showForm — только редактирование; окно создания восстанавливается после F5 (usePersistedOpen). 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(); const deleteTarget = useDeleteTarget(item => api.templateRoutes.remove(item.id), routes.reload); @@ -83,11 +92,13 @@ export const RoutesScreen: React.FC = ({ const openCreate = (): void => { setEditing(undefined); - setShowForm(true); + setShowForm(false); + createOpen.open(); }; const openEdit = (item: TemplateRoute): void => { setEditing(item); + createOpen.close(); setShowForm(true); }; @@ -159,7 +170,7 @@ export const RoutesScreen: React.FC = ({ emptyMessage={routes.isLoading ? texts.common.loading : texts.routes.emptyList} /> - {showForm && ( + {(showForm || createOpen.isOpen) && ( = ({ departments={portalDictionaries.departments} dictionaryLoading={portalDictionaries.isLoading} onSaved={routes.reload} - onClose={() => setShowForm(false)} + onClose={closeForm} /> )} diff --git a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts index 3c27a8c..4cd879f 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.test.ts @@ -32,6 +32,11 @@ describe('coerceStored', () => { expect(coerceStored([], [1, 2])).toEqual([1, 2]); }); + it('для boolean-флага отбрасывает значение другого типа', () => { + expect(coerceStored(false, true)).toBe(true); + expect(coerceStored(false, 'true')).toBe(false); + }); + it('при initial === undefined принимает сохранённое как есть', () => { expect(coerceStored(undefined, 'a')).toBe('a'); expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' }); diff --git a/frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts b/frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts new file mode 100644 index 0000000..d5f82a1 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/usePersistedOpen.ts @@ -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(key, false); + return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false) }; +}