fix(TaskDetail): фиксы per-field редактирования по тестам прода

- server: rich-text/company/task разрешены в PATCH field-values (была 400, поля не сохранялись)
- user-поле: дропдаун открывается сразу при редактировании, текущее значение всегда в списке
- select.tsx: viewport max-h-60 вместо высоты триггера (список показывал 1 пункт)
- плотность строк: min-h 28/36px, leading-5, read-only системные строки — py-1 как раньше
- вход в редактирование: double-click/double-tap (одиночный клик — только карандаш)
- textarea/table/task/company: компактная ширина редакторов, h-7
- сброс pendingAutofillRef при отмене; нативный select не отменяется по blur
This commit is contained in:
2026-08-03 22:20:30 +03:00
parent 8a8c798eb7
commit d562735eca
4 changed files with 118 additions and 44 deletions

View File

@@ -10,23 +10,9 @@ import {
} from '@/components/ui/select';
import type { FieldEditorProps } from '../FieldRegistry';
export const UserRoleEditor: React.FC<FieldEditorProps> = ({ field, value, onChange, users, roles }) => {
export const UserRoleEditor: React.FC<FieldEditorProps & { autoOpen?: boolean }> = ({ field, value, onChange, users, roles, autoOpen = false }) => {
const filter = (field.options as any)?.userFilter as { userIds?: number[]; roleIds?: number[] } | undefined;
const filteredUsers = useMemo(() => {
if (!users) return [];
if (!filter) return users; // нет фильтра — показываем всех
if (!filter.userIds || filter.userIds.length === 0) return []; // фильтр есть, но пользователи не выбраны
return users.filter((u) => filter.userIds!.includes(u.id));
}, [users, filter]);
const filteredRoles = useMemo(() => {
if (!roles) return [];
if (!filter) return roles; // нет фильтра — показываем все роли
if (!filter.roleIds || filter.roleIds.length === 0) return []; // фильтр есть, но роли не выбраны
return roles.filter((r) => filter.roleIds!.includes(r.id));
}, [roles, filter]);
// Нормализуем value в строку вида user:${id} / role:${id} или __none__
const stringValue = React.useMemo(() => {
if (value === null || value === undefined || value === '') return '__none__';
@@ -39,6 +25,43 @@ export const UserRoleEditor: React.FC<FieldEditorProps> = ({ field, value, onCha
return '__none__';
}, [value]);
const filteredUsers = useMemo(() => {
if (!users) return [];
let list = users;
if (filter) {
// фильтр есть, но пользователи не выбраны — пустой список
list = !filter.userIds || filter.userIds.length === 0 ? [] : users.filter((u) => filter.userIds!.includes(u.id));
}
// Текущее значение включаем всегда, даже если оно не проходит userFilter,
// иначе заполненное поле показывает пустой список и «не даёт себя изменить»
if (stringValue.startsWith('user:')) {
const currentId = Number(stringValue.slice(5));
if (!list.some((u) => u.id === currentId)) {
const currentUser = users.find((u) => u.id === currentId);
if (currentUser) list = [currentUser, ...list];
}
}
return list;
}, [users, filter, stringValue]);
const filteredRoles = useMemo(() => {
if (!roles) return [];
let list = roles;
if (filter) {
// фильтр есть, но роли не выбраны — пустой список
list = !filter.roleIds || filter.roleIds.length === 0 ? [] : roles.filter((r) => filter.roleIds!.includes(r.id));
}
// Текущая роль включается всегда (см. комментарий выше про пользователя)
if (stringValue.startsWith('role:')) {
const currentId = Number(stringValue.slice(5));
if (!list.some((r) => r.id === currentId)) {
const currentRole = roles.find((r) => r.id === currentId);
if (currentRole) list = [currentRole, ...list];
}
}
return list;
}, [roles, filter, stringValue]);
const handleChange = (v: string) => {
if (!v || v === '__none__') {
onChange(null);
@@ -51,7 +74,7 @@ export const UserRoleEditor: React.FC<FieldEditorProps> = ({ field, value, onCha
const hasRoles = filteredRoles.length > 0;
return (
<Select value={stringValue} onValueChange={handleChange}>
<Select value={stringValue} onValueChange={handleChange} defaultOpen={autoOpen}>
<SelectTrigger className="w-auto min-w-[200px]">
<SelectValue placeholder="— не выбрано —" />
</SelectTrigger>

View File

@@ -88,7 +88,9 @@ const SelectContent = React.forwardRef<
className={cn(
"p-1 overflow-y-auto",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
// max-h-60 вместо h-[--radix-select-trigger-height]: иначе viewport залочен
// на высоту триггера и длинный список показывает один пункт
"max-h-60 w-full min-w-[var(--radix-select-trigger-width)]"
)}
onWheel={(e) => {
const el = e.currentTarget;

View File

@@ -713,6 +713,19 @@ function FieldValueRow({
const [navGeo, setNavGeo] = useState<GeoValue | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Двойное касание на мобильных — вход в редактирование (как до per-field рефакторинга)
const lastTapRef = useRef<number>(0);
const handleDoubleTap = (e: React.TouchEvent) => {
if (!canEdit) return;
if (isInteractiveClickTarget(e.target)) return;
const now = Date.now();
if (now - lastTapRef.current < 300) {
e.stopPropagation();
enterEdit();
}
lastTapRef.current = now;
};
// Per-field режим редактирования: локальное состояние только этой строки
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState<unknown>(initialValue);
@@ -772,6 +785,8 @@ function FieldValueRow({
const cancelEdit = () => {
setIsEditing(false);
setLocalError(null);
// Сбрасываем очередь autofill-затирания (company onClear) — иначе она «утечёт» в следующее сохранение
pendingAutofillRef.current = [];
editValueRef.current = initialValue;
setEditValue(initialValue);
};
@@ -786,6 +801,9 @@ function FieldValueRow({
setLocalError(null);
if (!fieldValuesEqual(val, initialValue)) {
saveValue(val as string | number | boolean | Record<string, unknown> | null | undefined);
} else {
// Значение не изменилось — сохранение пропускаем, autofill-очередь затираем
pendingAutofillRef.current = [];
}
if (close) setIsEditing(false);
};
@@ -1061,7 +1079,7 @@ function FieldValueRow({
effectiveError && 'border-destructive ring-destructive'
);
const textareaClass = cn(
'border border-input rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-full min-w-[200px]',
'border border-input rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto min-w-[200px] max-w-[420px]',
effectiveError && 'border-destructive ring-destructive'
);
// Текст и число: сохранение по blur и Enter, Esc — отмена
@@ -1202,7 +1220,6 @@ function FieldValueRow({
onClick={e => e.stopPropagation()}
autoFocus
aria-invalid={!!effectiveError}
onBlur={() => cancelEdit()}
onKeyDown={e => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
}}
@@ -1263,6 +1280,7 @@ function FieldValueRow({
onChange={(val) => commitValue(val)}
users={users}
roles={roles}
autoOpen
/>
</div>
);
@@ -1281,7 +1299,7 @@ function FieldValueRow({
// Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст
if (field.type === 'company') {
return (
<div className="flex-1 min-w-0 max-w-[420px]" onClick={e => e.stopPropagation()}>
<div className="w-auto max-w-[420px] [&_input]:h-7 [&_input]:text-[13px]" onClick={e => e.stopPropagation()}>
<CompanyFieldInput
value={editValue === null || editValue === undefined ? '' : String(editValue)}
onChange={val => updateEditValue(val)}
@@ -1348,7 +1366,7 @@ function FieldValueRow({
// Мультивыбор строк: сохранение по каждому переключению
if (field.allowMultiple) {
return (
<div className="flex-1 min-w-0 max-w-[420px]" onClick={e => e.stopPropagation()}>
<div className="w-auto max-w-[420px] [&_input]:h-7 [&_input]:text-[13px]" onClick={e => e.stopPropagation()}>
<TableFieldMultiSelect
fieldId={field.id}
fieldCode={field.code}
@@ -1365,7 +1383,7 @@ function FieldValueRow({
}
// Одиночный выбор строки: сохранение сразу по выбору
return (
<div className="flex-1 min-w-0 max-w-[420px]" onClick={e => e.stopPropagation()}>
<div className="w-auto max-w-[420px] [&_input]:h-7 [&_input]:text-[13px]" onClick={e => e.stopPropagation()}>
<TableFieldSelect
fieldId={field.id}
fieldCode={field.code}
@@ -1391,7 +1409,7 @@ function FieldValueRow({
// Связанная задача: сохранение сразу по выбору
if (field.type === 'task' && field.linkedFormId) {
return (
<div className="flex-1 min-w-0 max-w-[420px]" onClick={e => e.stopPropagation()}>
<div className="w-auto max-w-[420px] [&_input]:h-7 [&_input]:text-[13px]" onClick={e => e.stopPropagation()}>
<TaskFieldSelect
formField={field as any}
value={editValue as string | number | null | undefined}
@@ -1438,7 +1456,7 @@ function FieldValueRow({
<>
<div
ref={wrapperRef}
className="group/field flex items-center gap-1 min-h-[40px] md:min-h-[32px] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50"
className="group/field flex items-center gap-1 min-h-[36px] md:min-h-[28px] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50"
>
{/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */}
<div className="w-6 h-5 shrink-0 flex items-center justify-center">
@@ -1480,7 +1498,7 @@ function FieldValueRow({
{/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<strong className={cn('shrink-0 text-[13px] leading-6', effectiveError && 'text-destructive')}>
<strong className={cn('shrink-0 text-[13px] leading-5', effectiveError && 'text-destructive')}>
{field.name}:
</strong>
{isInstantToggle ? (
@@ -1494,7 +1512,7 @@ function FieldValueRow({
onClick={(e) => e.stopPropagation()}
aria-invalid={!!effectiveError}
/>
<span className="text-[13px] leading-6">
<span className="text-[13px] leading-5">
{initialValue === true || initialValue === 'true' ? 'Да' : 'Нет'}
</span>
</label>
@@ -1504,13 +1522,15 @@ function FieldValueRow({
</div>
) : (
<div
className={cn('min-w-0 text-[13px] leading-6', canEdit && 'cursor-text')}
onClick={(e) => {
className={cn('min-w-0 text-[13px] leading-5', canEdit && 'cursor-text')}
title={canEdit ? 'Двойной клик для редактирования' : undefined}
onDoubleClick={(e) => {
if (!canEdit) return;
if (isInteractiveClickTarget(e.target)) return;
e.stopPropagation();
enterEdit();
}}
onTouchEnd={handleDoubleTap}
>
{renderReadValue()}
</div>
@@ -2037,6 +2057,19 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
const dueDraftRef = useRef({ date: '', time: '' });
const dueEditorRef = useRef<HTMLDivElement>(null);
// Двойное касание на мобильных — вход в редактирование срока
const lastDueTapRef = useRef<number>(0);
const handleDueDoubleTap = (e: React.TouchEvent) => {
if (readOnly) return;
if (isInteractiveClickTarget(e.target)) return;
const now = Date.now();
if (now - lastDueTapRef.current < 300) {
e.stopPropagation();
enterDueEdit();
}
lastDueTapRef.current = now;
};
const enterDueEdit = () => {
if (readOnly || isEditingDue) return;
const date = task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : '';
@@ -2259,7 +2292,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
<div ref={wrapperRef} className="space-y-1 text-sm">
{/* Assignee + Roles */}
{!hidden.has('assignedTo') && (
<div className="group/sysrow flex items-center gap-1 min-h-[40px] md:min-h-[32px] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50">
<div className="group/sysrow flex items-center gap-1 min-h-[36px] md:min-h-[28px] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50">
{!readOnly
? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); })
: emptySlot}
@@ -2467,7 +2500,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
);
return (
<div
className="group/sysrow flex items-center gap-1 min-h-[40px] md:min-h-[32px] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50"
className="group/sysrow flex items-center gap-1 min-h-[36px] md:min-h-[28px] py-0.5 -mx-1 px-1 rounded transition-colors hover:bg-muted/50"
>
{emptySlot}
<span className="text-muted-foreground w-24 shrink-0 text-[11px]">Срок</span>
@@ -2475,12 +2508,14 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
dueDateEditor
) : (
<div
className={cn('text-[13px] leading-6', !readOnly && 'cursor-text')}
onClick={(e) => {
className={cn('text-[13px] leading-5', !readOnly && 'cursor-text')}
title={!readOnly ? 'Двойной клик для редактирования' : undefined}
onDoubleClick={(e) => {
if (readOnly) return;
if (isInteractiveClickTarget(e.target)) return;
enterDueEdit();
}}
onTouchEnd={handleDueDoubleTap}
>
{dueDateReadView}
</div>
@@ -2491,10 +2526,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
{/* Created (read-only) */}
{!hidden.has('createdAt') && (
<div className="flex items-center gap-1 min-h-[32px] py-0.5">
<div className="flex items-center gap-1 py-1">
{emptySlot}
<span className="text-muted-foreground w-24 shrink-0 text-[11px]">Создана</span>
<span data-testid="text-task-created" className="text-[13px] leading-6">
<span data-testid="text-task-created" className="text-[13px] leading-5">
{task.createdAt
? new Date(task.createdAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: ''}
@@ -2504,10 +2539,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
{/* Completed at (read-only, shown only when task is in final status) */}
{isTaskFinal && !hidden.has('completedAt') && (
<div className="flex items-center gap-1 min-h-[32px] py-0.5">
<div className="flex items-center gap-1 py-1">
{emptySlot}
<span className="text-muted-foreground w-24 shrink-0 text-[11px]">Завершена</span>
<span data-testid="text-task-completed" className="text-[13px] leading-6">
<span data-testid="text-task-completed" className="text-[13px] leading-5">
{task.completedAt
? new Date(task.completedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: '—'}
@@ -2517,10 +2552,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
{/* Creator (read-only) */}
{!hidden.has('createdBy') && task.createdBy && (
<div className="flex items-center gap-1 min-h-[32px] py-0.5">
<div className="flex items-center gap-1 py-1">
{emptySlot}
<span className="text-muted-foreground w-24 shrink-0 text-[11px]">Автор</span>
<span data-testid="text-task-creator" className="text-[13px] leading-6">{getUserName(task.createdBy)}</span>
<span data-testid="text-task-creator" className="text-[13px] leading-5">{getUserName(task.createdBy)}</span>
</div>
)}
@@ -2874,14 +2909,28 @@ function TaskTitleInline({ task, taskIdNumber, formId, isTaskFinal }: { task: Ta
);
}
const lastTitleTapRef = useRef<number>(0);
const enterTitleEdit = () => {
if (isTaskFinal) return;
setDraft(task.title);
draftRef.current = task.title;
setIsEditing(true);
};
return (
<div
className={cn('group flex items-center gap-1 min-w-0', !isTaskFinal && 'cursor-text')}
onClick={() => {
title={!isTaskFinal ? 'Двойной клик для редактирования' : undefined}
onDoubleClick={enterTitleEdit}
onTouchEnd={(e) => {
if (isTaskFinal) return;
setDraft(task.title);
draftRef.current = task.title;
setIsEditing(true);
const now = Date.now();
if (now - lastTitleTapRef.current < 300) {
e.stopPropagation();
enterTitleEdit();
}
lastTitleTapRef.current = now;
}}
>
<h1 className={`text-sm font-medium break-words md:truncate ${isTaskFinal ? 'line-through text-muted-foreground' : 'text-foreground'}`} data-testid="text-task-title">