fix: нейтральный текст при пустом маршруте в заявке 1С
Tests / frontend-test (push) Successful in 1m19s

This commit is contained in:
Тимур Городецкий
2026-09-25 09:33:38 +05:00
parent 1884007161
commit ab21683377
13 changed files with 61 additions and 22 deletions
@@ -8,8 +8,8 @@ import {
Select,
PeopleField,
PeopleFieldMulti,
MultiChoiceField,
} from "../../../shared/ui";
import { MultiChoiceField } from "./MultiChoiceField";
import {
ISectionProps,
PERSON_PLACEHOLDER,
@@ -1,104 +0,0 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: block;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
@@ -1,136 +0,0 @@
import * as React from 'react';
import { useMemo, useState } from 'react';
import styles from './MultiChoiceField.module.scss';
import { useFormReadOnly, ISelectOption } from '../../../shared/ui';
export interface IMultiChoiceFieldProps {
value: string[];
onChange: (values: string[]) => void;
options: ISelectOption[];
/** Разрешить ввод произвольного значения, которого нет в списке. */
allowOther?: boolean;
placeholder?: string;
}
const MAX_SUGGESTIONS = 30;
/**
* Мультивыбор из фиксированного списка в стиле пикера-справочника
* (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
* фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
* выбранные из списка исключаются. allowOther позволяет добавить чипом
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
*/
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
value,
onChange,
options,
allowOther,
placeholder
}) => {
const readOnly = useFormReadOnly();
const [query, setQuery] = useState<string>('');
const [open, setOpen] = useState<boolean>(false);
const selected = useMemo(() => new Set(value), [value]);
// Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
const suggestions = useMemo(() => {
const q = query.trim().toLowerCase();
// Пустой запрос — показываем все ещё не выбранные опции (клик-дропдаун);
// с запросом — фильтруем по label (типа-хед).
const available = options.filter(
(o) => !selected.has(o.value) && (!q || o.label.toLowerCase().indexOf(q) !== -1)
);
return available.slice(0, MAX_SUGGESTIONS);
}, [options, query, selected]);
// «Другое»: если разрешено и введён текст, которого нет ни среди опций, ни среди
// уже выбранных — предлагаем добавить его как произвольное значение.
const trimmed = query.trim();
const canAddOther =
!!allowOther &&
trimmed.length > 0 &&
!selected.has(trimmed) &&
!options.some((o) => o.value.toLowerCase() === trimmed.toLowerCase());
const add = (v: string): void => {
if (!v || selected.has(v)) return;
onChange([...value, v]);
setQuery('');
// Список НЕ закрываем: поле сохраняет фокус (mousedown с preventDefault),
// поэтому оставляем открытым — сразу видно оставшиеся варианты и можно
// добавлять подряд, не теряя и не возвращая фокус.
setOpen(true);
};
const remove = (v: string): void => onChange(value.filter((x) => x !== v));
return (
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.chips}>
{value.map((v) => (
<span key={v} className={styles.chip}>
{labelFor(v)}
{!readOnly && (
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать ${labelFor(v)}`}
title="Убрать"
onClick={() => remove(v)}
>
×
</button>
)}
</span>
))}
</div>
)}
{!readOnly && (
<div className={styles.field}>
<input
className={styles.input}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && (suggestions.length > 0 || canAddOther) && (
<div className={styles.list}>
{suggestions.map((o) => (
<button
key={o.value}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(o.value)}
>
{o.label}
</button>
))}
{canAddOther && (
<button
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(trimmed)}
>
Добавить «{trimmed}»
</button>
)}
</div>
)}
</div>
)}
</div>
);
};
@@ -1,8 +1,7 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
import { PositionPicker } from '../../../shared/dictionary';
import { LanguagesField } from './LanguagesField';
import { MultiChoiceField } from './MultiChoiceField';
import {
ISectionProps,
EMPLOYMENT_TYPE_OPTIONS,
@@ -226,7 +226,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
data.TaskName.trim() &&
data.WorkType &&
data.Organization &&
data.DB1C &&
data.DB1C.length > 0 &&
data.Question.trim()
);
const validate = (): string | undefined => {
@@ -412,7 +412,10 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix
editor={matrixEditor}
search={(q) => approval.searchUsers(q)}
emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования."
// Пустой маршрут — штатная ситуация: для части видов работ согласование
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
// показывается отдельно (matrix.error) и блокирует отправку.
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
/>
<SubmitActions
@@ -68,7 +68,7 @@ function buildPlannerNotes(
lines.push(`Телефон: ${data.Phone}`);
lines.push(`Отдел: ${data.Department}`);
lines.push(`Должность: ${data.Position}`);
lines.push(`База 1С: ${data.DB1C}`);
lines.push(`База 1С: ${data.DB1C.join(', ')}`);
lines.push(`Виды работ: ${data.WorkType}`);
lines.push('');
lines.push('Вопрос:');
@@ -3,10 +3,24 @@ import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
// DB1C — отдельно, это множественный выбор (см. toStringArray).
const SCALAR_FIELDS: (keyof IFormData)[] = [
'WorkType', 'Organization', 'DB1C', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
'WorkType', 'Organization', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
];
/**
* Значение колонки множественного выбора → string[]. Строка — заявки, сохранённые,
* пока «База 1С» была одиночным выбором (или колонку ещё не переключили);
* { results } — ответ в verbose-формате OData.
*/
function toStringArray(raw: unknown): string[] {
if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string' && v !== '');
if (raw && typeof raw === 'object' && Array.isArray((raw as { results?: unknown }).results)) {
return toStringArray((raw as { results: unknown[] }).results);
}
return typeof raw === 'string' && raw !== '' ? [raw] : [];
}
export interface ILoadedRequest {
data: IFormData;
status: string;
@@ -26,7 +40,7 @@ export async function loadTicketRequest(itemId: number, listId?: string): Promis
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId)
.select(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
.select(...SCALAR_FIELDS, 'DB1C', 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>;
@@ -36,6 +50,7 @@ export async function loadTicketRequest(itemId: number, listId?: string): Promis
writable[key] = value === null || value === undefined ? '' : String(value);
});
data.TaskName = (item.Title as string) || '';
data.DB1C = toStringArray(item.DB1C);
return {
data,
@@ -16,7 +16,8 @@ export interface IFormData {
TaskName: string;
WorkType: string;
Organization: string;
DB1C: string;
/** Базы 1С — множественный выбор (колонка MultiChoice), минимум одна. */
DB1C: string[];
FIO: string;
Email: string;
Phone: string;
@@ -45,7 +46,7 @@ export const EMPTY_DATA: IFormData = {
TaskName: '',
WorkType: '',
Organization: '',
DB1C: '',
DB1C: [],
FIO: '',
Email: '',
Phone: '',
@@ -25,7 +25,7 @@
"ОСН Беркут",
"CT Global"
] },
{ "internalName": "DB1C", "title": "База 1С", "type": "Choice", "required": true, "choices": [
{ "internalName": "DB1C", "title": "База 1С", "type": "Choice", "allowMultiple": true, "required": true, "choices": [
"1С: Зарплата и управление персоналом (ЗУП)",
"1С: Документооборот (ДО)",
"1С: Автотранспортное предприятие (АТП)",
@@ -1,5 +1,5 @@
import * as React from 'react';
import { CollapseSection, FormField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { WORK_TYPE_OPTIONS, ORGANIZATION_OPTIONS, DB1C_OPTIONS, QUESTION_MAX } from '../config';
@@ -32,13 +32,12 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => (
placeholder="— выберите —"
/>
</FormField>
<FormField label="База 1С" htmlFor="DB1C" required>
<Select
id="DB1C"
<FormField label="База 1С" fieldKey="DB1C" required>
<MultiChoiceField
value={data.DB1C}
onChange={(v) => set('DB1C', v)}
options={DB1C_SELECT_OPTIONS}
placeholder="— выберите —"
placeholder="Выберите одну или несколько баз"
/>
</FormField>
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>