feat: подсветка незаполненных полей и переход к ним в приёме и подборе

Пользователи не понимали, почему кнопка «Отправить на согласование» серая.
Теперь в заявках на приём и подбор кнопки активны: нажатие при незаполненных
или неверных полях ничего не сохраняет, а подсвечивает эти поля с подсказкой
под ними и прокручивает к первому. Над кнопками — список проблем, пункты ведут
к полю. Механизм общий (FieldIssuesProvider + FormField), остальные формы
пока работают по-старому и переводятся по тому же паттерну.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-30 17:15:16 +05:00
parent c95118a216
commit 5702939b52
14 changed files with 485 additions and 125 deletions
@@ -0,0 +1,70 @@
import * as React from 'react';
import { useContext, useState } from 'react';
import { IFieldIssue, issueFieldText, scrollToField } from './fieldIssues';
/** Какую кнопку нажимали: от этого зависит, какие поля подсвечивать. */
export type RevealMode = 'draft' | 'submit';
interface IFieldIssuesContext {
draftIssues: IFieldIssue[];
submitIssues: IFieldIssue[];
/** Подсвечиваемые сейчас поля: ключ поля → текст под ним. */
visible: Map<string, string>;
/** Показать проблемы под полями и прокрутить к полю (по умолчанию — к первому). */
reveal: (mode: RevealMode, field?: string) => void;
}
const FieldIssuesContext = React.createContext<IFieldIssuesContext | undefined>(undefined);
interface IFieldIssuesProviderProps {
/** Что мешает сохранить черновик (обычно подмножество submitIssues). */
draftIssues: IFieldIssue[];
/** Что мешает отправке на согласование. */
submitIssues: IFieldIssue[];
children: React.ReactNode;
}
function toVisibleMap(issues: IFieldIssue[]): Map<string, string> {
const map = new Map<string, string>();
issues.forEach((issue) => {
// Первое сообщение по полю важнее: правила идут в порядке формы.
if (!map.has(issue.field)) map.set(issue.field, issueFieldText(issue));
});
return map;
}
/**
* Подсветка незаполненных/неверных полей формы. До первого нажатия кнопки
* поля не краснеют (пустая форма не должна пугать); после — подсвечиваются
* и гаснут сами, как только поле исправлено: списки пересчитываются из данных.
*/
export const FieldIssuesProvider: React.FC<IFieldIssuesProviderProps> = ({ draftIssues, submitIssues, children }) => {
const [mode, setMode] = useState<RevealMode | undefined>(undefined);
const reveal = (nextMode: RevealMode, field?: string): void => {
// Раз показали проблемы отправки — не сужаем обратно до черновика.
setMode((prev) => (prev === 'submit' ? prev : nextMode));
const issues = nextMode === 'draft' ? draftIssues : submitIssues;
const target = field || (issues.length > 0 ? issues[0].field : undefined);
if (target) scrollToField(target);
};
let shown: IFieldIssue[] = [];
if (mode === 'submit') shown = submitIssues;
if (mode === 'draft') shown = draftIssues;
const value: IFieldIssuesContext = { draftIssues, submitIssues, visible: toVisibleMap(shown), reveal };
return <FieldIssuesContext.Provider value={value}>{children}</FieldIssuesContext.Provider>;
};
/** Управление подсветкой для кнопок формы; undefined — форма без FieldIssuesProvider. */
export function useFieldIssuesControl(): IFieldIssuesContext | undefined {
return useContext(FieldIssuesContext);
}
/** Текст проблемы под полем, если его сейчас надо подсветить. */
export function useFieldIssue(field: string | undefined): string | undefined {
const context = useContext(FieldIssuesContext);
if (!context || !field) return undefined;
return context.visible.get(field);
}
@@ -0,0 +1,53 @@
/** Проблема с полем формы, из-за которой заявку нельзя сохранить/отправить. */
export interface IFieldIssue {
/** Ключ поля — тот же, что fieldKey/htmlFor у FormField: по нему подсветка и прокрутка. */
field: string;
/** Подпись в списке над кнопками — как у поля на форме, с номером. */
label: string;
/** Уточнение со строчной буквы («выберите вариант из списка»). Не задано — «заполните поле». */
message?: string;
}
const DEFAULT_ISSUE_MESSAGE = 'заполните поле';
/** Атрибут-метка обёртки поля в FormField — по нему ищем поле для прокрутки. */
export const FIELD_ANCHOR_ATTR = 'data-rp-field';
/** Атрибут-метка списка проблем над кнопками — запасная цель прокрутки. */
export const ISSUES_ANCHOR_ATTR = 'data-rp-issues';
const FOCUSABLE = 'input:not([type="hidden"]):not([disabled]), select, textarea, button:not([disabled])';
/** Строка для списка над кнопками: «1.3 Проект — выберите вариант из списка». */
export function issueListText(issue: IFieldIssue): string {
return issue.message ? `${issue.label} — ${issue.message}` : issue.label;
}
/** Текст под полем: «Выберите вариант из списка» / «Заполните поле». */
export function issueFieldText(issue: IFieldIssue): string {
const text = issue.message || DEFAULT_ISSUE_MESSAGE;
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** Подписи проблем одной строкой — для текста ошибки: «1.4 Телефон, 1.5 Тип приёма». */
export function issueLabels(issues: IFieldIssue[]): string {
return issues.map((i) => i.label).join(', ');
}
/**
* Прокрутить к полю и поставить в него курсор. Поле может отсутствовать в DOM —
* CollapseSection из ui-kit убирает тело свёрнутой секции целиком; тогда
* прокручиваем к списку проблем над кнопками, там поле названо.
*/
export function scrollToField(field: string): void {
const anchor =
document.querySelector<HTMLElement>(`[${FIELD_ANCHOR_ATTR}="${field}"]`) ||
document.querySelector<HTMLElement>(`[${ISSUES_ANCHOR_ATTR}]`);
if (!anchor) return;
anchor.scrollIntoView({ behavior: 'smooth', block: 'center' });
const focusable = anchor.querySelector<HTMLElement>(FOCUSABLE);
if (focusable) {
// preventScroll: иначе фокус мгновенно дёрнет страницу и собьёт плавную прокрутку.
focusable.focus({ preventScroll: true });
}
}
@@ -20,6 +20,22 @@
font-weight: 600;
}
// Незаполненное/неверное поле после нажатия «Отправить» (см. FieldIssuesProvider).
// Полоса слева, а не рамка контрола: у пикеров, мультивыборов и переключателей
// своей рамки нет или она во Fluent — полоса одинаково видна у любого поля.
.invalid {
padding-left: $sp-2;
border-left: 3px solid $red-500;
border-radius: $r-sm;
}
.issue {
margin-top: $sp-1;
font-size: $fs-caption;
color: $red-500;
overflow-wrap: anywhere;
}
// На печати подсветка не нужна — бумажная форма показывает итоговые значения.
@media print {
.changed {
@@ -3,6 +3,8 @@ import { FormField as KitFormField } from '@tnsu/ui-kit';
import styles from './FormField.module.scss';
import { useFieldChange } from '../FieldChanges/FieldChangesContext';
import { formatDateTime } from '../FieldChanges/formatDateTime';
import { useFieldIssue } from '../FieldIssues/FieldIssuesContext';
import { FIELD_ANCHOR_ATTR } from '../FieldIssues/fieldIssues';
interface IFormFieldProps {
label: string;
@@ -11,30 +13,43 @@ interface IFormFieldProps {
/** Занять обе колонки сетки. */
full?: boolean;
/**
* Ключ поля в журнале правок (internalName колонки). По умолчанию — htmlFor;
* задаётся явно у полей без htmlFor (люди, мультивыбор, переключатели).
* Ключ поля в журнале правок (internalName колонки) и в списке проблем формы
* (FieldIssuesProvider). По умолчанию — htmlFor; задаётся явно у полей без
* htmlFor (люди, мультивыбор, переключатели).
*/
fieldKey?: string;
children: React.ReactNode;
}
/**
* FormField из ui-kit + подсветка правки согласующего: если поле есть в
* журнале (см. FieldChangesProvider), контрол подсвечивается, а под ним —
* прежнее значение. Без журнала ведёт себя ровно как FormField из ui-kit.
* FormField из ui-kit + две подсветки:
* - правка согласующего (см. FieldChangesProvider): контрол выделен, под ним — прежнее значение;
* - проблема поля (см. FieldIssuesProvider): красная полоса и текст под контролом.
*
* Обёртка с меткой поля есть всегда, а не только при ошибке: иначе при появлении
* и исчезновении ошибки React пересоздавал бы контрол и сбрасывал фокус посреди ввода.
*/
export const FormField: React.FC<IFormFieldProps> = ({ fieldKey, children, ...fieldProps }) => {
const change = useFieldChange(fieldKey ?? fieldProps.htmlFor);
if (!change) {
return <KitFormField {...fieldProps}>{children}</KitFormField>;
}
const key = fieldKey ?? fieldProps.htmlFor;
const change = useFieldChange(key);
const issue = useFieldIssue(key);
const anchorProps = key ? { [FIELD_ANCHOR_ATTR]: key } : {};
return (
<KitFormField {...fieldProps}>
<div className={styles.changed}>{children}</div>
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
<span className={styles.beforeLabel}>Было:</span> {change.before}
<div {...anchorProps} className={issue ? styles.invalid : undefined}>
{change ? <div className={styles.changed}>{children}</div> : children}
</div>
{change && (
<div className={`${styles.before} rp-no-print`} title={`Изменено: ${change.byName}, ${formatDateTime(change.at)}`}>
<span className={styles.beforeLabel}>Было:</span> {change.before}
</div>
)}
{issue && (
<div className={`${styles.issue} rp-no-print`} role="alert">
{issue}
</div>
)}
</KitFormField>
);
};
@@ -2,6 +2,8 @@ import * as React from 'react';
import { Button, Icon, FormActions, Alert } from '@tnsu/ui-kit';
import { IRequestSubmit } from '../useRequestSubmit';
import { isCurrentUserTester } from './testerGroupAccess';
import { SubmitIssues } from './SubmitIssues';
import { useFieldIssuesControl } from '../FieldIssues/FieldIssuesContext';
/**
* ВРЕМЕННАЯ ЗАМОРОЗКА (включена 2026-09-21 по просьбе Тимура Городецкого):
@@ -65,6 +67,11 @@ export interface ISubmitActionsProps {
// Логика доступности — здесь, в одном месте, чтобы все формы вели себя одинаково:
// • сохранять/отправлять нельзя, пока идёт сохранение или заявка уже read-only;
// • «Отмена» доступна всегда (возврат к списку), кроме момента сохранения.
//
// Форма внутри FieldIssuesProvider (см. shared/ui/FieldIssues) ведёт себя иначе:
// кнопки сохранения не гаснут из-за незаполненных полей — нажатие при проблемах
// ничего не сохраняет, а подсвечивает поля и прокручивает к первому. Над кнопками
// список проблем со ссылками на поля. Формы без провайдера — как раньше, по isValid.
export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submit,
isValid,
@@ -98,11 +105,33 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester;
const locked = saving || readOnly || frozen;
const canSubmit = submitValid ?? isValid;
const issuesControl = useFieldIssuesControl();
// С провайдером кнопки не гаснут по полям: проблемы показываются по нажатию.
const canSaveDraft = issuesControl ? true : isValid;
const canSubmit = issuesControl ? true : submitValid ?? isValid;
const handleSaveDraft = (): void => {
if (issuesControl && issuesControl.draftIssues.length > 0) {
issuesControl.reveal('draft');
return;
}
onSaveDraft();
};
const handleSubmit = (): void => {
if (issuesControl && issuesControl.submitIssues.length > 0) {
issuesControl.reveal('submit');
return;
}
onSubmit();
};
return (
<>
{frozen && !readOnly && <Alert variant="warn">Подача заявок временно недоступна.</Alert>}
{/* При заморозке кнопки неактивны не из-за полей — список только сбил бы с толку. */}
{issuesControl && !frozen && !readOnly && (
<SubmitIssues issues={issuesControl.submitIssues} onSelect={(field) => issuesControl.reveal('submit', field)} />
)}
<FormActions>
{onDelete && (
<Button variant="danger" onClick={onDelete} disabled={saving}>
@@ -113,11 +142,11 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
</Button>
<Button variant="secondary" onClick={onSaveDraft} disabled={!isValid || locked}>
<Button variant="secondary" onClick={handleSaveDraft} disabled={!canSaveDraft || locked}>
<Icon name="document" size="sm" />
Сохранить черновик
</Button>
<Button variant="accent" onClick={onSubmit} disabled={!canSubmit || locked}>
<Button variant="accent" onClick={handleSubmit} disabled={!canSubmit || locked}>
<Icon name="send" size="sm" />
{saving && kind === 'submit' ? 'Отправка…' : submitLabel}
</Button>
@@ -0,0 +1,24 @@
@import '../../../../../styles/design-tokens';
.title {
font-weight: 600;
}
.list {
margin: $sp-1 0 0;
padding-left: $sp-5;
}
// Пункт списка — ссылка на поле: выглядит как текст с подчёркиванием, без рамки кнопки.
.link {
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
overflow-wrap: anywhere;
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Alert } from '@tnsu/ui-kit';
import styles from './SubmitIssues.module.scss';
import { IFieldIssue, ISSUES_ANCHOR_ATTR, issueListText } from '../FieldIssues/fieldIssues';
interface ISubmitIssuesProps {
/** Что мешает отправке — в порядке формы. */
issues: IFieldIssue[];
/** Клик по пункту — прокрутка к полю. */
onSelect: (field: string) => void;
}
const TITLE = 'Чтобы отправить на согласование, заполните или исправьте:';
/**
* Список причин, по которым заявку пока нельзя отправить. Пункт кликабелен —
* ведёт к полю: на длинной форме иначе пришлось бы искать его самому.
*/
export const SubmitIssues: React.FC<ISubmitIssuesProps> = ({ issues, onSelect }) => {
if (issues.length === 0) return null;
const anchorProps = { [ISSUES_ANCHOR_ATTR]: '' };
return (
<div {...anchorProps}>
<Alert variant="warn">
<div className={styles.title}>{TITLE}</div>
<ul className={styles.list}>
{issues.map((issue) => (
<li key={`${issue.field}-${issue.label}`}>
<button type="button" className={styles.link} onClick={() => onSelect(issue.field)}>
{issueListText(issue)}
</button>
</li>
))}
</ul>
</Alert>
</div>
);
};
@@ -52,6 +52,9 @@ export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
export { LinkField } from './LinkField/LinkField';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { FieldIssuesProvider } from './FieldIssues/FieldIssuesContext';
export type { IFieldIssue } from './FieldIssues/fieldIssues';
export { issueLabels } from './FieldIssues/fieldIssues';
export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler';
export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';