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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user