fix: делает "Предложение по улучшению" необязательным для жалобы, убирает дубль заголовка

Каждый вид обращения раньше показывал одинаковый набор обязательных полей.
getAppealFieldsConfig теперь описывает и видимость, и обязательность отдельно:
жалоба — предложение по-прежнему можно оставить, но требовать его как для
самостоятельного вида "Предложение" неверно. validateFeedback читает то же
правило, что и форма, — иначе одно поле могло бы требовать то, что другое
не показывает.

FormCard в FeedbackForm дублировал заголовок и подзаголовок PageShell — убран
(пустой title у ui-kit не создаёт строки, гэпа не остаётся).

Подсказка у переключателя "Обращение анонимное" разбита на две сноски (* и **),
как в исходной форме, а не слитный абзац.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 09:44:48 +05:00
parent 51339b3f9a
commit 05cd491769
6 changed files with 53 additions and 29 deletions
+3
View File
@@ -12,6 +12,9 @@
color: $gray-500; color: $gray-500;
font-family: $font-sans; font-family: $font-sans;
font-size: $fs-caption; font-size: $fs-caption;
// Подсказка может содержать несколько сносок через \n (см. anonymousHint) —
// pre-line превращает переносы строк в тексте в реальные строки на экране.
white-space: pre-line;
} }
// Красная рамка у контрола внутри — ui-kit не знает о состоянии ошибки, // Красная рамка у контрола внутри — ui-kit не знает о состоянии ошибки,
+4 -1
View File
@@ -24,7 +24,10 @@ export const FeedbackForm: React.FC = () => {
} }
return ( return (
<FormCard title={TEXTS.formTitle} subtitle={TEXTS.pageSubtitle}> // FormCard требует title, но заголовок страницы уже показан в PageShell
// (App.tsx) — второй здесь был бы дублем. Пустая строка убирает текст,
// а гэпа не оставляет: у ui-kit пустой title-блок не создаёт строки.
<FormCard title="">
{formError ? ( {formError ? (
<div className={styles.notice}> <div className={styles.notice}>
<Alert variant="err">{formError}</Alert> <Alert variant="err">{formError}</Alert>
+24 -9
View File
@@ -45,27 +45,42 @@ export const KIND_OPTIONS: ISelectOption[] = [
{ value: 'other', label: 'Другое' } { value: 'other', label: 'Другое' }
]; ];
/** Какие текстовые поля показываем для выбранного вида обращения. */ /** Показывать поле или нет, и если показывать — обязательно ли оно. */
export interface IVisibleAppealFields { export interface IAppealFieldConfig {
situation: boolean; visible: boolean;
suggestion: boolean; required: boolean;
}
/** Настройка текстовых полей для выбранного вида обращения. */
export interface IAppealFieldsConfig {
situation: IAppealFieldConfig;
suggestion: IAppealFieldConfig;
} }
/** /**
* Состав полей зависит от вида обращения: * Состав полей зависит от вида обращения:
* жалоба — что случилось и что предлагаете изменить; * жалоба — обязательно описание, а предложение — на желание автора;
* предложение — только само предложение, «ситуации» ещё нет; * предложение — только само предложение, «ситуации» ещё нет;
* другое — свободное описание, без предложения. * другое — свободное описание, без предложения.
* Одно правило на форму и на валидацию, чтобы они не разошлись. * Одно правило на форму и на валидацию, чтобы они не разошлись.
*/ */
export function getVisibleAppealFields(kind: AppealKind): IVisibleAppealFields { export function getAppealFieldsConfig(kind: AppealKind): IAppealFieldsConfig {
switch (kind) { switch (kind) {
case 'complaint': case 'complaint':
return { situation: true, suggestion: true }; return {
situation: { visible: true, required: true },
suggestion: { visible: true, required: false }
};
case 'suggestion': case 'suggestion':
return { situation: false, suggestion: true }; return {
situation: { visible: false, required: false },
suggestion: { visible: true, required: true }
};
case 'other': case 'other':
return { situation: true, suggestion: false }; return {
situation: { visible: true, required: true },
suggestion: { visible: false, required: false }
};
} }
} }
@@ -1,6 +1,6 @@
import { FormGrid, Select, TextArea, TextInput } from '@tnsu/ui-kit'; import { FormGrid, Select, TextArea, TextInput } from '@tnsu/ui-kit';
import { Field } from '../../../components/Field/Field'; import { Field } from '../../../components/Field/Field';
import { getVisibleAppealFields, KIND_OPTIONS, type AppealKind } from '../model'; import { getAppealFieldsConfig, KIND_OPTIONS, type AppealKind } from '../model';
import { TEXTS } from '../texts'; import { TEXTS } from '../texts';
import type { ISectionProps } from './ISectionProps'; import type { ISectionProps } from './ISectionProps';
@@ -9,7 +9,7 @@ const SUGGESTION_ROWS = 4;
/** Суть обращения: вид и зависящие от него текстовые поля. */ /** Суть обращения: вид и зависящие от него текстовые поля. */
export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) => { export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) => {
const visible = getVisibleAppealFields(data.kind); const fields = getAppealFieldsConfig(data.kind);
const isOther = data.kind === 'other'; const isOther = data.kind === 'other';
return ( return (
@@ -39,11 +39,11 @@ export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
</Field> </Field>
) : null} ) : null}
{visible.situation ? ( {fields.situation.visible ? (
<Field <Field
label={isOther ? TEXTS.labels.situationOther : TEXTS.labels.situation} label={isOther ? TEXTS.labels.situationOther : TEXTS.labels.situation}
htmlFor="situation" htmlFor="situation"
required required={fields.situation.required}
full full
error={errors.situation} error={errors.situation}
> >
@@ -59,11 +59,11 @@ export const AppealSection: React.FC<ISectionProps> = ({ data, errors, set }) =>
</Field> </Field>
) : null} ) : null}
{visible.suggestion ? ( {fields.suggestion.visible ? (
<Field <Field
label={TEXTS.labels.suggestion} label={TEXTS.labels.suggestion}
htmlFor="suggestion" htmlFor="suggestion"
required required={fields.suggestion.required}
full full
error={errors.suggestion} error={errors.suggestion}
> >
+4 -3
View File
@@ -6,10 +6,11 @@ export const TEXTS = {
pageTitle: 'Обращение', pageTitle: 'Обращение',
pageSubtitle: 'Данное обращение будет направлено в HR департамент', pageSubtitle: 'Данное обращение будет направлено в HR департамент',
formTitle: 'Обращение в HR департамент', // Две сноски с разным числом звёздочек — как в исходной форме, чтобы читалось
// как два отдельных примечания, а не слитный абзац. Field рендерит их построчно.
anonymousHint: anonymousHint:
'Анонимное обращение: мы не сможем дать вам обратную связь. Именные обращения обрабатываются в течение 3–5 рабочих дней, если вопрос не требует большего срока.', '* Если обращение анонимное, мы не сможем дать вам обратную связь.\n' +
'** Именные обращения обрабатываются в течение 3–5 рабочих дней, если вопрос не требует большего срока.',
labels: { labels: {
company: 'Компания', company: 'Компания',
+12 -10
View File
@@ -1,4 +1,4 @@
import { getVisibleAppealFields, type FeedbackErrors, type IFeedbackData } from './model'; import { getAppealFieldsConfig, type FeedbackErrors, type IFeedbackData } from './model';
import { TEXTS } from './texts'; import { TEXTS } from './texts';
/** Ниже этой длины текст не считаем содержательным описанием. */ /** Ниже этой длины текст не считаем содержательным описанием. */
@@ -26,22 +26,24 @@ export function validateFeedback(data: IFeedbackData): FeedbackErrors {
errors.kindOther = TEXTS.errors.required; errors.kindOther = TEXTS.errors.required;
} }
// Спрашиваем только то, что реально показано для выбранного вида обращения: // Правило видимости/обязательности общее с формой (AppealSection), поэтому
// иначе форма ругалась бы на скрытое поле, и пользователь не понял бы, где ошибка. // валидируем по нему же — иначе форма ругалась бы на скрытое или необязательное поле.
const visible = getVisibleAppealFields(data.kind); const fields = getAppealFieldsConfig(data.kind);
if (visible.situation) { if (fields.situation.visible) {
if (!data.situation.trim()) { const value = data.situation.trim();
if (fields.situation.required && !value) {
errors.situation = TEXTS.errors.required; errors.situation = TEXTS.errors.required;
} else if (data.situation.trim().length < MIN_TEXT_LENGTH) { } else if (value && value.length < MIN_TEXT_LENGTH) {
errors.situation = TEXTS.errors.tooShort; errors.situation = TEXTS.errors.tooShort;
} }
} }
if (visible.suggestion) { if (fields.suggestion.visible) {
if (!data.suggestion.trim()) { const value = data.suggestion.trim();
if (fields.suggestion.required && !value) {
errors.suggestion = TEXTS.errors.required; errors.suggestion = TEXTS.errors.required;
} else if (data.suggestion.trim().length < MIN_TEXT_LENGTH) { } else if (value && value.length < MIN_TEXT_LENGTH) {
errors.suggestion = TEXTS.errors.tooShort; errors.suggestion = TEXTS.errors.tooShort;
} }
} }