feat: inline-редактирование полей через overlay как в Twenty
- Добавлены компоненты InlineField и FieldOverlay для рендера editor поверх read-view - TaskDetail: FieldValueRow, title и due date переведены на overlay-редактирование - TaskRegistry: ячейки реестра используют InlineField - TaskRegularTable: ячейки таблицы-вкладки используют InlineField - RegistryCellEditor: textarea на AutoResizeTextarea с minRows=1/maxRows=8 - Поля не разбегаются при входе в режим редактирования, textarea растёт внутри оверлея
This commit is contained in:
@@ -9,6 +9,7 @@ import ContractNumberField from '@/components/ContractNumberField';
|
||||
import ContactField from '@/components/ContactField';
|
||||
import { normalizeSelectOptions } from '@/lib/fieldOptions';
|
||||
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
|
||||
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
|
||||
|
||||
interface FormFieldData {
|
||||
id: number;
|
||||
@@ -166,9 +167,10 @@ export default function RegistryCellEditor({
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
className={`${inputClass} resize-none`}
|
||||
rows={2}
|
||||
<AutoResizeTextarea
|
||||
className={`${inputClass} resize-none px-1 py-0`}
|
||||
minRows={1}
|
||||
maxRows={8}
|
||||
value={editValue === null || editValue === undefined ? '' : String(editValue)}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
|
||||
import RegistryCellEditor from './RegistryCellEditor';
|
||||
import { InlineField } from '@/components/fields/InlineField';
|
||||
|
||||
interface TaskAssigneeInfo {
|
||||
userId: number;
|
||||
@@ -1059,18 +1060,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
const rawValue = task.fieldValues?.[fieldId];
|
||||
return field ? (
|
||||
<TableCell key={key} className="py-2">
|
||||
{isEditing(task.id, key) ? (
|
||||
<RegistryCellEditor
|
||||
taskId={task.id}
|
||||
field={field}
|
||||
value={rawValue}
|
||||
users={users}
|
||||
roles={roles}
|
||||
formId={formId}
|
||||
onSaved={() => setEditingCell(null)}
|
||||
onCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
) : field.type === 'contract-number' ? (
|
||||
{field.type === 'contract-number' ? (
|
||||
<span className="text-sm">
|
||||
<ContractNumberDisplay
|
||||
value={rawValue}
|
||||
@@ -1089,14 +1079,35 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||
</button>
|
||||
) : REGISTRY_INLINE_TYPES.has(field.type) ? (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
|
||||
>
|
||||
<span className="text-sm">
|
||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
<InlineField
|
||||
readView={
|
||||
<span className="text-sm">
|
||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
}
|
||||
editor={
|
||||
<RegistryCellEditor
|
||||
taskId={task.id}
|
||||
field={field}
|
||||
value={rawValue}
|
||||
users={users}
|
||||
roles={roles}
|
||||
formId={formId}
|
||||
onSaved={() => setEditingCell(null)}
|
||||
onCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
}
|
||||
isEditing={editingCell?.taskId === task.id && editingCell?.columnKey === key}
|
||||
editable={true}
|
||||
onEnterEdit={() => setEditingCell({ taskId: task.id, columnKey: key })}
|
||||
onExitEdit={() => setEditingCell(null)}
|
||||
fitOverlay={field.type !== 'textarea' && field.type !== 'rich-text'}
|
||||
overlayClassName={field.type === 'textarea' || field.type === 'rich-text' ? 'flex flex-col overflow-hidden' : ''}
|
||||
overlayMaxHeight={field.type === 'textarea' || field.type === 'rich-text' ? 320 : undefined}
|
||||
overlayMinWidth={field.type === 'textarea' || field.type === 'rich-text' ? 300 : 160}
|
||||
closeOnClickOutside={false}
|
||||
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[24px] w-full'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm">
|
||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import type { TableColumnDef, RegularTableRow } from '@shared/schema';
|
||||
import { evaluateAllFormulas, evaluateAggregateFormula, FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine';
|
||||
import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions';
|
||||
import { InlineField } from '@/components/fields/InlineField';
|
||||
|
||||
interface DataTableCellProps {
|
||||
tableId: number;
|
||||
@@ -395,11 +396,12 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa
|
||||
);
|
||||
}
|
||||
|
||||
const isEditableCell = !readOnly && col.type !== 'select' && col.type !== 'table';
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.id}
|
||||
className="border px-0.5 py-0 cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => col.type !== 'select' && col.type !== 'table' && handleCellClick(row.id, col.id, cellValue)}
|
||||
className="border px-0.5 py-0 hover:bg-muted/50"
|
||||
data-testid={`cell-${row.id}-${col.id}`}
|
||||
>
|
||||
{col.type === 'select' && !readOnly ? (
|
||||
@@ -421,24 +423,37 @@ export default function TaskRegularTable({ taskId, tabId, columns, readOnly = fa
|
||||
onChange={handleSelectChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : isEditing ? (
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleCellBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
className="h-6 px-1 py-0 border-0 focus-visible:ring-1 bg-background"
|
||||
type={col.type === 'number' ? 'number' : col.type === 'date' ? 'date' : 'text'}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-[24px] flex items-center px-0.5 truncate">
|
||||
{cellValue === '__auto_prolongation__'
|
||||
? 'Автопролонгация'
|
||||
: col.type === 'select'
|
||||
? getSelectOptionLabel(col.options, cellValue)
|
||||
: cellValue}
|
||||
</div>
|
||||
<InlineField
|
||||
readView={
|
||||
<div className="min-h-[24px] flex items-center px-0.5 truncate cursor-pointer">
|
||||
{cellValue === '__auto_prolongation__'
|
||||
? 'Автопролонгация'
|
||||
: col.type === 'select'
|
||||
? getSelectOptionLabel(col.options, cellValue)
|
||||
: cellValue}
|
||||
</div>
|
||||
}
|
||||
editor={
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleCellBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
className="h-6 px-1 py-0 border-0 focus-visible:ring-1 bg-background w-full"
|
||||
type={col.type === 'number' ? 'number' : col.type === 'date' ? 'date' : 'text'}
|
||||
/>
|
||||
}
|
||||
isEditing={isEditing}
|
||||
editable={isEditableCell}
|
||||
onEnterEdit={() => handleCellClick(row.id, col.id, cellValue)}
|
||||
onExitEdit={() => { setEditingCell(null); setEditValue(''); }}
|
||||
activateOnClick={isEditableCell}
|
||||
fitOverlay={true}
|
||||
closeOnClickOutside={false}
|
||||
className="w-full min-h-[24px] items-center"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
|
||||
53
client/src/components/fields/FieldOverlay.tsx
Normal file
53
client/src/components/fields/FieldOverlay.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FieldOverlayProps {
|
||||
children: React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
className?: string;
|
||||
/** Если true, оверлей строго повторяет размер контейнера (inset-0). Если false — растягивается вниз/вправо от верхнего-левого угла контейнера. */
|
||||
fitContainer?: boolean;
|
||||
/** Минимальная ширина оверлея (например, чтобы пустое поле было удобно редактировать). */
|
||||
minWidth?: number | string;
|
||||
/** Максимальная высота оверлея. */
|
||||
maxHeight?: number | string;
|
||||
}
|
||||
|
||||
export function FieldOverlay({
|
||||
children,
|
||||
containerRef,
|
||||
className,
|
||||
fitContainer = true,
|
||||
minWidth,
|
||||
maxHeight,
|
||||
}: FieldOverlayProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted || !containerRef.current) return null;
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (minWidth !== undefined) style.minWidth = typeof minWidth === 'number' ? `${minWidth}px` : minWidth;
|
||||
if (maxHeight !== undefined) style.maxHeight = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 rounded-md border border-input bg-background shadow-sm overflow-hidden',
|
||||
fitContainer ? 'inset-0' : 'left-0 top-0 min-w-full',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
containerRef.current
|
||||
);
|
||||
}
|
||||
157
client/src/components/fields/InlineField.tsx
Normal file
157
client/src/components/fields/InlineField.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FieldOverlay } from './FieldOverlay';
|
||||
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || tag === 'button' || tag === 'a') return true;
|
||||
if (target.closest('button, a, input, textarea, select, [role="button"], [role="listbox"]')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface InlineFieldProps {
|
||||
/** Read-view контент. Остаётся в потоке, не размонтируется. */
|
||||
readView: React.ReactNode;
|
||||
/** Editor контент. Рендерится поверх read-view через portal. */
|
||||
editor: React.ReactNode;
|
||||
/** Управляемый режим редактирования. Если не передан — компонент управляет сам. */
|
||||
isEditing?: boolean;
|
||||
/** Можно ли входить в редактирование. */
|
||||
editable?: boolean;
|
||||
onEnterEdit?: (e?: React.MouseEvent | React.TouchEvent) => void;
|
||||
onExitEdit?: () => void;
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
/** Строго повторять размер контейнера. Для input/number/select — true, для textarea/rich-text — false. */
|
||||
fitOverlay?: boolean;
|
||||
/** Минимальная ширина оверлея. */
|
||||
overlayMinWidth?: number | string;
|
||||
/** Максимальная высота оверлея. */
|
||||
overlayMaxHeight?: number | string;
|
||||
/** Не блокировать клик по интерактивным элементам внутри read-view. */
|
||||
allowInteractiveReadView?: boolean;
|
||||
/** Закрывать редактор по клику вне (по умолчанию true). */
|
||||
closeOnClickOutside?: boolean;
|
||||
/** Активировать редактирование по одиночному клику (по умолчанию false — только double-click). */
|
||||
activateOnClick?: boolean;
|
||||
}
|
||||
|
||||
export function InlineField({
|
||||
readView,
|
||||
editor,
|
||||
isEditing: controlledIsEditing,
|
||||
editable = true,
|
||||
onEnterEdit,
|
||||
onExitEdit,
|
||||
className,
|
||||
overlayClassName,
|
||||
fitOverlay = true,
|
||||
overlayMinWidth = 200,
|
||||
overlayMaxHeight,
|
||||
allowInteractiveReadView = true,
|
||||
closeOnClickOutside = true,
|
||||
activateOnClick = false,
|
||||
}: InlineFieldProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [internalEditing, setInternalEditing] = useState(false);
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
const isEditing = controlledIsEditing !== undefined ? controlledIsEditing : internalEditing;
|
||||
|
||||
const enterEdit = useCallback((e?: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!editable) return;
|
||||
onEnterEdit?.(e);
|
||||
if (controlledIsEditing === undefined) {
|
||||
setInternalEditing(true);
|
||||
}
|
||||
}, [editable, onEnterEdit, controlledIsEditing]);
|
||||
|
||||
const exitEdit = useCallback(() => {
|
||||
onExitEdit?.();
|
||||
if (controlledIsEditing === undefined) {
|
||||
setInternalEditing(false);
|
||||
}
|
||||
}, [onExitEdit, controlledIsEditing]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (allowInteractiveReadView && isInteractiveTarget(e.target)) return;
|
||||
e.stopPropagation();
|
||||
enterEdit(e);
|
||||
},
|
||||
[allowInteractiveReadView, enterEdit]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!activateOnClick) return;
|
||||
if (allowInteractiveReadView && isInteractiveTarget(e.target)) return;
|
||||
e.stopPropagation();
|
||||
enterEdit(e);
|
||||
},
|
||||
[activateOnClick, allowInteractiveReadView, enterEdit]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (allowInteractiveReadView && isInteractiveTarget(e.target)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < DOUBLE_TAP_MS) {
|
||||
e.stopPropagation();
|
||||
enterEdit(e);
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
},
|
||||
[allowInteractiveReadView, enterEdit]
|
||||
);
|
||||
|
||||
// Закрытие по клику вне + игнорирование Radix-порталов
|
||||
useEffect(() => {
|
||||
if (!isEditing || !closeOnClickOutside) return;
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target || !(target instanceof Node)) return;
|
||||
|
||||
const isInsideContainer = containerRef.current?.contains(target) ?? false;
|
||||
const isInsideRadixPortal =
|
||||
target instanceof Element &&
|
||||
!!target.closest?.(
|
||||
'[data-radix-popper-content-wrapper], [role="listbox"], [data-radix-select-viewport], [data-radix-menu-content], [data-radix-popover-content]'
|
||||
);
|
||||
|
||||
if (!isInsideContainer && !isInsideRadixPortal) {
|
||||
exitEdit();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [isEditing, exitEdit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative inline-flex max-w-full', className)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{readView}
|
||||
{isEditing && (
|
||||
<FieldOverlay
|
||||
containerRef={containerRef}
|
||||
className={overlayClassName}
|
||||
fitContainer={fitOverlay}
|
||||
minWidth={overlayMinWidth}
|
||||
maxHeight={overlayMaxHeight}
|
||||
>
|
||||
{editor}
|
||||
</FieldOverlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user