Персист модалок: черновики, восстановление после F5, предупреждение при выходе (#30)
Tests / frontend-test (push) Successful in 1m39s
Tests / backend-test (push) Successful in 9m27s

Модалки по жёлтым кнопкам теряли введённое при случайном клике мимо окна.

**Формы создания** (типы заявок, доступ к заявкам, маршруты, шаги маршрута)
- Черновик полей хранится в localStorage (useFormDraft) и переживает закрытие окна, «Отмену» и F5.
- Окно, открытое в момент F5, открывается снова (usePersistedOpen); закрытие сбрасывает только признак, черновик остаётся.
- Черновик стирается после успешного сохранения или кнопкой «Очистить» в подвале.

**Формы редактирования**
- Не персистятся — открываются с данными сервера.
- При выходе с несохранёнными правками (крестик, «Отмена», клик мимо) — диалог «Закрыть без сохранения?» (isDirty в FormModal).

**Устойчивость к старым данным в localStorage**
- coerceStored приводит сохранённое значение к форме текущего дефолта по полям: новое поле — дефолт, удалённое отбрасывается, значение другого типа и null заменяются дефолтом. Покрыто тестами.

**Прочее**
- Modal и ConfirmDialog рендерятся порталом в body поверх шапки SharePoint — на живой странице верх высокого окна уезжал под шапку.
- Убраны Sass-предупреждения mixed-decls в Card.module.scss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/30
This commit was merged in pull request #30.
This commit is contained in:
2026-10-19 04:46:16 +00:00
parent 0e6a7064d2
commit f3b0f0ad48
26 changed files with 474 additions and 109 deletions
@@ -0,0 +1,44 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.Common;
/// <summary>
/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
/// </summary>
public static class StepLabels
{
public static string Of(HeadType headType) => headType switch
{
HeadType.Chief => "Непосредственный руководитель",
HeadType.DirectionHead => "Руководитель направления",
HeadType.DirectorTansu => "Директор Tansu",
HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
HeadType.DepartmentHead => "Руководитель указанного отдела",
HeadType.CompanyDirectors => "Директора компаний инициатора",
_ => headType.ToString()
};
/// <summary>Название с маленькой буквы — для середины фразы («Не найден руководитель направления»).</summary>
public static string InSentence(HeadType headType) => Lower(Of(headType));
/// <summary>
/// Все названия мужского рода («найден руководитель проекта»), кроме «Безопасность» —
/// для неё нужна отдельная формулировка, иначе выйдет «найден безопасность».
/// </summary>
public static string InSentence(ProjectRole projectRole) =>
projectRole == ProjectRole.Security ? "сотрудник службы безопасности" : Lower(Of(projectRole));
private static string Lower(string label) => char.ToLowerInvariant(label[0]) + label[1..];
public static string Of(ProjectRole projectRole) => projectRole switch
{
ProjectRole.ProjectManager => "Руководитель проекта",
ProjectRole.Planner => "Планировщик",
ProjectRole.ManagerHSE => "Менеджер HSE",
ProjectRole.Security => "Безопасность",
ProjectRole.Keeper => "Заведующий склада",
_ => projectRole.ToString()
};
}
@@ -1,6 +1,7 @@
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
@@ -44,25 +45,6 @@ public sealed class RouteExportService(
["CompaniesJson"] = "организация" ["CompaniesJson"] = "организация"
}; };
private static readonly Dictionary<ProjectRole, string> ProjectRoleLabels = new()
{
[ProjectRole.ProjectManager] = "Руководитель проекта",
[ProjectRole.Planner] = "Планировщик",
[ProjectRole.ManagerHSE] = "Менеджер HSE",
[ProjectRole.Security] = "Безопасность",
[ProjectRole.Keeper] = "Заведующий склада"
};
private static readonly Dictionary<HeadType, string> HeadTypeLabels = new()
{
[HeadType.Chief] = "Непосредственный руководитель",
[HeadType.DirectionHead] = "Руководитель направления",
[HeadType.DirectorTansu] = "Директор Tansu",
[HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu",
[HeadType.DepartmentHead] = "Руководитель указанного отдела",
[HeadType.CompanyDirectors] = "Директора компаний инициатора"
};
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new() private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
{ {
[ComparisonOperator.GreaterThan] = ">", [ComparisonOperator.GreaterThan] = ">",
@@ -228,7 +210,7 @@ public sealed class RouteExportService(
} }
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
return (ProjectRoleLabels[projectRole], ByRequest, Empty); return (StepLabels.Of(projectRole), ByRequest, Empty);
if (step.HeadType is { } headType && headType != HeadType.None) if (step.HeadType is { } headType && headType != HeadType.None)
{ {
@@ -240,9 +222,9 @@ public sealed class RouteExportService(
}; };
if (fixedEmail is not null) if (fixedEmail is not null)
// Нет в справочнике — лучше показать email, чем пустую ячейку. // Нет в справочнике — лучше показать email, чем пустую ячейку.
return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail)); return (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
return (HeadTypeLabels[headType], ByRequest, Empty); return (StepLabels.Of(headType), ByRequest, Empty);
} }
return (Empty, Empty, Empty); return (Empty, Empty, Empty);
@@ -1,6 +1,7 @@
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep var entity = new TemplateRouteStep
{ {
@@ -141,7 +142,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo; entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail; entity.ExecutorEmail = r.ExecutorEmail;
@@ -210,7 +211,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo; entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail; entity.ExecutorEmail = executorEmail;
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
@@ -437,7 +438,7 @@ public sealed class TemplateRouteService(
{ {
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
if (string.IsNullOrWhiteSpace(resolvedEmail)) if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}»."; warning = $"В проекте не найден {StepLabels.InSentence(step.ProjectRole.Value)}.";
} }
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке // DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
// подчинённости инициатора, а по отделу, переданному явно в запросе (см. // подчинённости инициатора, а по отделу, переданному явно в запросе (см.
@@ -469,7 +470,14 @@ public sealed class TemplateRouteService(
_ => null _ => null
}; };
if (string.IsNullOrWhiteSpace(resolvedEmail)) if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель типа «{step.HeadType}»."; {
var headLabel = StepLabels.InSentence(step.HeadType.Value);
// Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
// справочника сотрудников — там email не «не найден», а «не проставлен».
warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
? $"Не проставлен {headLabel}."
: $"Не найден {headLabel}.";
}
} }
else else
{ {
@@ -612,7 +620,7 @@ public sealed class TemplateRouteService(
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode) else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
{ {
var conflictWarning = var conflictWarning =
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}."; $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning } var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
.Where(w => !string.IsNullOrWhiteSpace(w))); .Where(w => !string.IsNullOrWhiteSpace(w)));
result[index] = existing with { Warning = combinedWarning }; result[index] = existing with { Warning = combinedWarning };
@@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief); resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
var warning = resolved.Value.Steps[0].Warning; var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull(); warning.ShouldNotBeNull();
warning.ShouldContain("Chief"); warning.ShouldContain("Непосредственный руководитель");
warning.ShouldContain("DirectionHead"); warning.ShouldContain("Руководитель направления");
warning.ShouldNotContain("DirectionHead");
} }
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")] [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
@@ -1050,7 +1051,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
var warning = resolved.Value.Steps[0].Warning; var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull(); warning.ShouldNotBeNull();
warning.ShouldContain("руководитель"); warning.ShouldBe("Не найден непосредственный руководитель.");
} }
[Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")] [Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")]
@@ -1108,7 +1109,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
var warning = resolved.Value.Steps[0].Warning; var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull(); warning.ShouldNotBeNull();
warning.ShouldContain("роль"); warning.ShouldBe("В проекте не найден сотрудник службы безопасности.");
} }
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
@@ -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
@@ -9,6 +9,8 @@ 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 { 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';
@@ -41,6 +43,17 @@ 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';
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.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,
@@ -51,9 +64,11 @@ 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 [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,9 +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 => {
setRequestTypeId(''); createOpen.open();
setEmails([]);
setShowForm(true);
}; };
const submitForm = (): void => { const submitForm = (): void => {
@@ -128,10 +141,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) {
setShowForm(false); draft.reset();
createOpen.close();
return; return;
} }
setEmails(failedEmails); setEmails(() => failedEmails);
throw new Error(texts.requestTypeAccess.createFailed(failures)); throw new Error(texts.requestTypeAccess.createFailed(failures));
}); });
}; };
@@ -228,13 +242,15 @@ 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}
canReset={draft.isDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.requestTypeAccess.requestTypeLabel} required full> <Field label={texts.requestTypeAccess.requestTypeLabel} required full>
@@ -242,7 +258,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,8 @@ 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 { usePersistedOpen } from '../../hooks/usePersistedOpen';
import { import {
ActionsCell, ActionsCell,
Alert, Alert,
@@ -57,6 +59,22 @@ 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';
const CREATE_OPEN_STORAGE_KEY = 'approvalAdmin.draftOpen.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 не работает нигде.
@@ -78,8 +96,18 @@ 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 [form, setForm] = React.useState<IFormState>(EMPTY_FORM); 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 [editForm, setEditForm] = React.useState<IFormState>(EMPTY_FORM);
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 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,21 +120,14 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
const openCreate = (): void => { const openCreate = (): void => {
setEditing(undefined); setEditing(undefined);
setForm(EMPTY_FORM); setShowForm(false);
setShowForm(true); createOpen.open();
}; };
const openEdit = (item: RequestType): void => { const openEdit = (item: RequestType): void => {
setEditing(item); setEditing(item);
setForm({ setEditForm(toFormState(item));
name: item.name, createOpen.close();
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
});
setShowForm(true); setShowForm(true);
}; };
@@ -143,7 +164,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
submit.run(() => submit.run(() =>
request.then(() => { request.then(() => {
setShowForm(false); if (!editing) draft.reset();
closeForm();
list.reload(); list.reload();
}) })
); );
@@ -192,13 +214,16 @@ 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}
canReset={draft.isDirty}
isDirty={isEditDirty}
> >
<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,9 @@ 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}
isDirty={Boolean(editing) && 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,9 @@ 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}
isDirty={Boolean(editing) && draft.isDirty}
> >
<FormGrid> <FormGrid>
<Field label={texts.routeSteps.orderNoLabel} required> <Field label={texts.routeSteps.orderNoLabel} required>
@@ -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}
/> />
)} )}
@@ -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: '';
@@ -1,12 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import styles from './Modal.module.scss'; import styles from './Modal.module.scss';
import { Button } from './Button'; import { Button } from './Button';
import { ModalOverlay } from './ModalOverlay';
import { texts } from '../../texts'; import { texts } from '../../texts';
export interface IConfirmDialogProps { 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;
@@ -16,18 +19,19 @@ export const ConfirmDialog: React.FC<IConfirmDialogProps> = ({
title, title,
text, text,
confirmLabel, confirmLabel,
cancelLabel = texts.common.cancel,
onConfirm, onConfirm,
onCancel, onCancel,
busy busy
}) => ( }) => (
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onCancel(); }}> <ModalOverlay onBackdropClick={onCancel}>
<div className={`${styles.box} ${styles.narrow}`}> <div className={`${styles.box} ${styles.narrow}`}>
<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>
</div> </ModalOverlay>
); );
@@ -1,7 +1,9 @@
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 { texts } from '../../texts'; import { texts } from '../../texts';
export interface IFormModalProps { export interface IFormModalProps {
@@ -15,6 +17,18 @@ export interface IFormModalProps {
savingLabel?: string; savingLabel?: string;
onSubmit: () => void; onSubmit: () => void;
onClose: () => void; onClose: () => void;
/**
* Сбросить черновик формы (см. useFormDraft). Кнопка «Очистить» показывается,
* только если передан и canReset — «Отмена» и клик мимо окна черновик не трогают.
*/
onReset?: () => void;
canReset?: boolean;
/**
* В форме есть несохранённые правки существующей записи: выход (крестик,
* «Отмена», клик мимо окна) сначала спрашивает подтверждение. Для форм создания
* не нужен — их черновик сохраняется сам (см. useFormDraft).
*/
isDirty?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -30,14 +44,32 @@ export const FormModal: React.FC<IFormModalProps> = ({
savingLabel = texts.common.saving, savingLabel = texts.common.saving,
onSubmit, onSubmit,
onClose, onClose,
onReset,
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={
<> <>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button> {onReset && canReset && (
<span className={styles.footerStart}>
<Button variant="ghost" icon="refresh" onClick={onReset} disabled={isSaving}>{texts.common.resetDraft}</Button>
</span>
)}
<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>
@@ -47,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)}
/>
)}
</>
);
};
@@ -7,9 +7,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 300; // Выше шапки SharePoint, но ниже слоёв Fluent UI (1000000). Оверлей рендерится
// порталом в body (см. ModalOverlay), поэтому сравнивается с шапкой напрямую.
z-index: 100000;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
padding: 20px; padding: 20px;
// Портал вне корня веб-части — шрифт и метрики .shell (AdminApp.module.scss)
// сюда не наследуются, задаём заново.
font-family: $font-sans;
font-size: $fs-body;
line-height: 1.55;
box-sizing: border-box;
} }
.box { .box {
@@ -63,4 +71,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; }
@@ -1,6 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import styles from './Modal.module.scss'; import styles from './Modal.module.scss';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { ModalOverlay } from './ModalOverlay';
import { texts } from '../../texts'; import { texts } from '../../texts';
export interface IModalProps { export interface IModalProps {
@@ -11,8 +12,7 @@ export interface IModalProps {
} }
export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => ( export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children }) => (
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget). <ModalOverlay onBackdropClick={onClose}>
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.box}> <div className={styles.box}>
<div className={styles.header}> <div className={styles.header}>
<div className={styles.title}>{title}</div> <div className={styles.title}>{title}</div>
@@ -21,5 +21,5 @@ export const Modal: React.FC<IModalProps> = ({ title, onClose, footer, children
<div className={styles.body}>{children}</div> <div className={styles.body}>{children}</div>
{footer && <div className={styles.footer}>{footer}</div>} {footer && <div className={styles.footer}>{footer}</div>}
</div> </div>
</div> </ModalOverlay>
); );
@@ -0,0 +1,25 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import styles from './Modal.module.scss';
export interface IModalOverlayProps {
/** Клик по подложке (не по содержимому окна). */
onBackdropClick: () => void;
children: React.ReactNode;
}
/**
* Затемнение под модалкой — порталом в document.body, а не внутри веб-части.
* На живой странице SharePoint канва веб-части лежит в своём контексте наложения
* ниже шапки (suite bar), и position: fixed-оверлей изнутри неё оказывался ПОД
* шапкой: верх окна, центрированного по высоте экрана, уезжал за неё. В workbench
* шапки нет — там было незаметно.
*/
export const ModalOverlay: React.FC<IModalOverlayProps> = ({ onBackdropClick, children }) =>
ReactDOM.createPortal(
// Клик по подложке закрывает окно, клик внутри — нет (проверяем сам currentTarget).
<div className={styles.overlay} onClick={e => { if (e.target === e.currentTarget) onBackdropClick(); }}>
{children}
</div>,
document.body
);
@@ -1,6 +1,6 @@
@import '../../../../styles/design-tokens'; @import '../../../../styles/design-tokens';
// Ниже Modal.module.scss/.overlay (z-index 300) — диалоги, которые может // Ниже Modal.module.scss/.overlay (z-index 100000) — диалоги, которые может
// открыть сама длительная операция (например, разрешение конфликта), должны // открыть сама длительная операция (например, разрешение конфликта), должны
// быть ПОВЕРХ этой подложки, а не под ней. // быть ПОВЕРХ этой подложки, а не под ней.
.overlay { .overlay {
@@ -73,7 +73,7 @@
box-shadow: $sh-modal; box-shadow: $sh-modal;
border: 1px solid $gray-200; border: 1px solid $gray-200;
padding: 6px; padding: 6px;
z-index: 400; z-index: 100001; // над оверлеем модалки (Modal.module.scss)
max-height: 320px; max-height: 320px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -17,6 +17,7 @@ export * from './PrimaryCell';
export * from './ActionsCell'; export * from './ActionsCell';
export * from './CellNote'; export * from './CellNote';
export * from './Modal'; export * from './Modal';
export * from './ModalOverlay';
export * from './ConfirmDialog'; export * from './ConfirmDialog';
export * from './FormModal'; export * from './FormModal';
export * from './FormGrid'; export * from './FormGrid';
@@ -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 };
}
@@ -0,0 +1,44 @@
import { coerceStored } from './useLocalStorageState';
describe('coerceStored', () => {
const initial = { name: '', count: 0, flag: false, tags: [] as string[], nested: { a: '' } };
it('подставляет дефолт для полей, добавленных после сохранения', () => {
expect(coerceStored(initial, { name: 'x' })).toEqual({ ...initial, name: 'x' });
});
it('отбрасывает ключи, которых в форме больше нет', () => {
expect(coerceStored(initial, { name: 'x', removed: 1 })).not.toHaveProperty('removed');
});
it('заменяет значение другого типа дефолтом', () => {
expect(coerceStored(initial, { name: 5, tags: { a: 1 }, flag: 'yes', nested: 'oops' }))
.toEqual(initial);
});
it('принимает массив нужного типа и мёржит вложенный объект', () => {
expect(coerceStored(initial, { tags: ['a'], nested: {} }))
.toEqual({ ...initial, tags: ['a'] });
});
it('не падает на null и примитиве вместо объекта', () => {
expect(coerceStored(initial, null)).toEqual(initial);
expect(coerceStored(initial, 'x')).toEqual(initial);
});
it('для примитивного initial проверяет тип', () => {
expect(coerceStored('', 5)).toBe('');
expect(coerceStored('', 'abc')).toBe('abc');
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 принимает сохранённое как есть', () => {
expect(coerceStored<string | undefined>(undefined, 'a')).toBe('a');
expect(coerceStored({ x: undefined as string | undefined }, { x: 'a' })).toEqual({ x: 'a' });
});
});
@@ -5,27 +5,55 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
} }
/** /**
* Как React.useState, но значение переживает перезагрузку страницы — хранится * Сохранённое значение, приведённое к форме initial. Запись могла быть сделана
* в localStorage под key. Падение доступа к localStorage (приватный режим, * старой версией формы, поэтому ей нельзя доверять слепо:
* квота) не ломает форму — просто не персистит. * - объект мёржится с initial — добавленные с тех пор поля получают дефолт;
* * - ключи, которых в initial нет (поле удалили), отбрасываются;
* Сохранённое значение мелко мёржится с initial (для объектных T): если после * - значение, чей тип не совпал с initial (было число — стала строка, было
* обновления в форму добавили новое поле, у пользователей со старой записью в * [] — стал объект), заменяется дефолтом: иначе .map/.trim по нему уронил бы рендер;
* localStorage оно не потеряется как undefined (что уронило бы рендер на * - null/undefined в initial типа не задают — там принимаем любое сохранённое.
* первом же .map/.trim по этому полю), а подставится дефолт из initial.
*/ */
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] { export function coerceStored<T>(initial: T, stored: unknown): T {
const [value, setValue] = React.useState<T>(() => { if (initial === null || initial === undefined) return stored as T;
if (Array.isArray(initial)) return (Array.isArray(stored) ? stored : initial) as unknown as T;
if (isPlainObject(initial)) {
if (!isPlainObject(stored)) return initial;
const result: Record<string, unknown> = { ...initial };
Object.keys(initial).forEach(k => {
if (k in stored) result[k] = coerceStored(initial[k], stored[k]);
});
return result as T;
}
return (typeof stored === typeof initial ? stored : initial) as T;
}
/**
* Значение из localStorage под key, приведённое к форме initial (см. coerceStored),
* или initial, если записи нет / она битая / localStorage недоступен.
*/
export function readLocalStorage<T>(key: string, initial: T): T {
try { try {
const raw = window.localStorage.getItem(key); const raw = window.localStorage.getItem(key);
if (!raw) return initial; if (!raw) return initial;
const parsed = JSON.parse(raw) as T; return coerceStored(initial, JSON.parse(raw));
return isPlainObject(initial) && isPlainObject(parsed) ? { ...initial, ...parsed } as T : parsed;
} catch { } catch {
return initial; return initial;
} }
}); }
/**
* Как React.useState, но значение переживает перезагрузку страницы — хранится
* в localStorage под key. Падение доступа к localStorage (приватный режим,
* квота) не ломает форму — просто не персистит.
*
* Сохранённое значение приводится к форме initial (см. coerceStored): новое поле
* получает дефолт, удалённое отбрасывается, значение не того типа заменяется
* дефолтом — иначе старая запись в localStorage уронила бы рендер на первом же
* .map/.trim по этому полю.
*/
export function useLocalStorageState<T>(key: string, initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = React.useState<T>(() => readLocalStorage(key, initial));
React.useEffect(() => { React.useEffect(() => {
try { try {
@@ -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) };
}
@@ -76,6 +76,13 @@ export const texts = {
remove: 'Удалить', remove: 'Удалить',
edit: 'Редактировать', edit: 'Редактировать',
close: 'Закрыть', close: 'Закрыть',
/** Сброс черновика формы создания — сам черновик переживает закрытие окна. */
resetDraft: 'Очистить',
/** Предупреждение при выходе из формы редактирования с несохранёнными правками. */
discardTitle: 'Закрыть без сохранения?',
discardText: 'Внесённые изменения не сохранятся.',
discardConfirm: 'Закрыть без сохранения',
discardKeepEditing: 'Продолжить редактирование',
loading: 'Загрузка...', loading: 'Загрузка...',
yes: 'Да', yes: 'Да',
no: 'Нет', no: 'Нет',