feat(registry): горизонтальная прокрутка внизу области, sticky-шапка, ресайз колонок, лимит строк в ячейке
- ui/table: пропс wrapperClassName (twMerge), в реестре wrapper = w-max min-w-full overflow-visible — переполнение по ширине всплывает в контейнер реестра, скроллбар оказывается внизу видимой области, а не под всей таблицей - TableHeader sticky top-0 (фон tr для plain-варианта скина) - drag правого края шапки меняет ширину колонки (table-fixed + colgroup, min 80 / max 1200 px), двойной клик — сброс; ширины хранятся в localStorage per-form (registry-colwidths-<formId>) — у каждого пользователя свои - настройка «Строк в ячейке» (Авто / 1..8) в диалоге настроек реестра: CSS line-clamp на текстовых ячейках, хранится в registry-rowlines-<formId>; оценка высоты виртуализатора ниже в однострочном режиме check/build чисто, vitest 129/129
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
import { useState, useMemo, useEffect, useCallback, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||||
import { useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -36,9 +36,11 @@ import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient';
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
|
import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
|
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
|
||||||
import RegistryCellEditor from './RegistryCellEditor';
|
import RegistryCellEditor from './RegistryCellEditor';
|
||||||
import { InlineField } from '@/components/fields/InlineField';
|
import { InlineField } from '@/components/fields/InlineField';
|
||||||
|
import { useSkinComponents } from '@/themes/ThemeProvider';
|
||||||
|
|
||||||
interface TaskAssigneeInfo {
|
interface TaskAssigneeInfo {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -264,11 +266,12 @@ const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']);
|
|||||||
|
|
||||||
// Порог виртуализации реестра: при меньшем числе строк рендерим как обычно
|
// Порог виртуализации реестра: при меньшем числе строк рендерим как обычно
|
||||||
const REGISTRY_VIRTUALIZATION_THRESHOLD = 30;
|
const REGISTRY_VIRTUALIZATION_THRESHOLD = 30;
|
||||||
// Оценка высоты строки до измерения (h-10 по UI Style Guide)
|
|
||||||
const REGISTRY_ROW_ESTIMATED_HEIGHT = 40;
|
|
||||||
|
|
||||||
const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, anchorTaskId, onToggleSelected, onToggleSelectAll, onSelectRange, onSetAnchorTaskId, onClearSelection, onRequestBulkDelete }: TaskRegistryProps) => {
|
const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, anchorTaskId, onToggleSelected, onToggleSelectAll, onSelectRange, onSetAnchorTaskId, onClearSelection, onRequestBulkDelete }: TaskRegistryProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
// Вид шапки таблицы из скина: для plain-варианта sticky-шапке нужен явный фон,
|
||||||
|
// иначе при прокрутке сквозь неё проступают строки
|
||||||
|
const { table: skinTable } = useSkinComponents();
|
||||||
|
|
||||||
// Текущая редактируемая ячейка: { taskId, columnKey }
|
// Текущая редактируемая ячейка: { taskId, columnKey }
|
||||||
const [editingCell, setEditingCell] = useState<{ taskId: number; columnKey: string } | null>(null);
|
const [editingCell, setEditingCell] = useState<{ taskId: number; columnKey: string } | null>(null);
|
||||||
@@ -422,6 +425,77 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
const [showCompleted, setShowCompleted] = useState(() => {
|
const [showCompleted, setShowCompleted] = useState(() => {
|
||||||
try { return JSON.parse(localStorage.getItem(`registry-completed-${formId}`) || 'false'); } catch { return false; }
|
try { return JSON.parse(localStorage.getItem(`registry-completed-${formId}`) || 'false'); } catch { return false; }
|
||||||
});
|
});
|
||||||
|
// Максимум строк текста в ячейке: 'auto' (по умолчанию, текущее поведение) или 1..8.
|
||||||
|
// При 1 строка задачи всегда однострочная с ellipsis (полный текст в title).
|
||||||
|
const [maxLines, setMaxLines] = useState<number | 'auto'>(() => {
|
||||||
|
try {
|
||||||
|
const v = Number(JSON.parse(localStorage.getItem(`registry-rowlines-${formId}`) || 'null'));
|
||||||
|
return Number.isFinite(v) && v >= 1 && v <= 8 ? Math.floor(v) : 'auto';
|
||||||
|
} catch { return 'auto'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSS-стиль клампа многострочного текста; undefined в режиме auto
|
||||||
|
const clampStyle = (lines: number | 'auto'): CSSProperties | undefined =>
|
||||||
|
lines === 'auto'
|
||||||
|
? undefined
|
||||||
|
: { display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' };
|
||||||
|
|
||||||
|
// Ширины колонок, заданные пользователем перетаскиванием правого края шапки.
|
||||||
|
// Колонки без записи — автоширина. Хранится в localStorage, как и прочие настройки реестра.
|
||||||
|
const [columnWidths, setColumnWidths] = useState<{ [key: string]: number }>(() => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(`registry-colwidths-${formId}`) || '{}');
|
||||||
|
return v && typeof v === 'object' ? v : {};
|
||||||
|
} catch { return {}; }
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(`registry-colwidths-${formId}`, JSON.stringify(columnWidths)); } catch { /* ignore */ }
|
||||||
|
}, [columnWidths, formId]);
|
||||||
|
const hasCustomWidths = Object.keys(columnWidths).length > 0;
|
||||||
|
const resetColumnWidth = (key: string) => setColumnWidths(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const startColumnResize = (columnKey: string, e: ReactPointerEvent<HTMLElement>) => {
|
||||||
|
if (e.pointerType !== 'mouse' || e.button !== 0) return;
|
||||||
|
const th = e.currentTarget.closest('th');
|
||||||
|
if (!th) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = th.getBoundingClientRect().width;
|
||||||
|
const prevCursor = document.body.style.cursor;
|
||||||
|
const prevUserSelect = document.body.style.userSelect;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const w = Math.min(1200, Math.max(80, Math.round(startWidth + ev.clientX - startX)));
|
||||||
|
setColumnWidths(prev => ({ ...prev, [columnKey]: w }));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
document.body.style.cursor = prevCursor;
|
||||||
|
document.body.style.userSelect = prevUserSelect;
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Заголовок колонки с ручкой ресайза (drag по правому краю, двойной клик — сброс ширины)
|
||||||
|
const RegistryHead = ({ columnKey, children }: { columnKey: string; children?: ReactNode }) => (
|
||||||
|
<TableHead className="relative">
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize select-none touch-none z-10 hidden md:block"
|
||||||
|
title="Перетащите, чтобы изменить ширину. Двойной клик — сброс"
|
||||||
|
onPointerDown={e => startColumnResize(columnKey, e)}
|
||||||
|
onDoubleClick={e => { e.stopPropagation(); resetColumnWidth(columnKey); }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
@@ -443,6 +517,10 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
try { localStorage.setItem(`registry-completed-${formId}`, JSON.stringify(showCompleted)); } catch { /* ignore */ }
|
try { localStorage.setItem(`registry-completed-${formId}`, JSON.stringify(showCompleted)); } catch { /* ignore */ }
|
||||||
}, [showCompleted, formId]);
|
}, [showCompleted, formId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(`registry-rowlines-${formId}`, JSON.stringify(maxLines)); } catch { /* ignore */ }
|
||||||
|
}, [maxLines, formId]);
|
||||||
|
|
||||||
// При появлении новых полей добавляем их в состояние (не затирая сохранённые)
|
// При появлении новых полей добавляем их в состояние (не затирая сохранённые)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleColumns(prev => {
|
setVisibleColumns(prev => {
|
||||||
@@ -556,6 +634,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
// Виртуализация строк реестра: рендерятся только видимые строки + overscan.
|
// Виртуализация строк реестра: рендерятся только видимые строки + overscan.
|
||||||
// Фильтрация/сортировка/выделение работают по полному filteredTasks и не затронуты.
|
// Фильтрация/сортировка/выделение работают по полному filteredTasks и не затронуты.
|
||||||
const tableScrollRef = useRef<HTMLDivElement>(null);
|
const tableScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Оценка высоты строки до измерения (h-10 по UI Style Guide; однострочный режим ниже)
|
||||||
|
const REGISTRY_ROW_ESTIMATED_HEIGHT = maxLines === 'auto' ? 40 : 32;
|
||||||
const shouldVirtualize = filteredTasks.length > REGISTRY_VIRTUALIZATION_THRESHOLD;
|
const shouldVirtualize = filteredTasks.length > REGISTRY_VIRTUALIZATION_THRESHOLD;
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: filteredTasks.length,
|
count: filteredTasks.length,
|
||||||
@@ -768,6 +848,24 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="show-completed">Показать завершённые</Label>
|
<Label htmlFor="show-completed">Показать завершённые</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Максимум строк текста в ячейке */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-2 border-b">
|
||||||
|
<Label htmlFor="registry-max-lines">Строк в ячейке</Label>
|
||||||
|
<Select
|
||||||
|
value={String(maxLines)}
|
||||||
|
onValueChange={v => setMaxLines(v === 'auto' ? 'auto' : Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="registry-max-lines" className="h-8 w-32" data-testid="select-max-lines">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Авто</SelectItem>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8].map(n => (
|
||||||
|
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<DndContext sensors={columnSensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
|
<DndContext sensors={columnSensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
|
||||||
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
||||||
<div className="space-y-1 max-h-[50vh] overflow-y-auto pr-1">
|
<div className="space-y-1 max-h-[50vh] overflow-y-auto pr-1">
|
||||||
@@ -874,9 +972,16 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border w-fit min-w-full">
|
||||||
<Table>
|
<Table wrapperClassName="w-max min-w-full overflow-visible" className={hasCustomWidths ? 'table-fixed' : ''}>
|
||||||
<TableHeader>
|
{/* Ширины колонок, заданные пользователем (drag шапки); без записи — автоширина */}
|
||||||
|
<colgroup>
|
||||||
|
{isAdmin && <col style={{ width: 32 }} />}
|
||||||
|
{columnOrder.filter(k => visibleColumns[k]).map(k => (
|
||||||
|
<col key={k} style={columnWidths[k] ? { width: columnWidths[k] } : undefined} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
<TableHeader className={`sticky top-0 z-10${skinTable.header === 'plain' ? ' [&_tr]:bg-background' : ''}`}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TableHead className="w-8 px-2">
|
<TableHead className="w-8 px-2">
|
||||||
@@ -897,70 +1002,70 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
{columnOrder.filter(key => visibleColumns[key]).map(key => {
|
{columnOrder.filter(key => visibleColumns[key]).map(key => {
|
||||||
if (key === 'title') {
|
if (key === 'title') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="title" label="Название" filterValue={filters['title'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('title')} />
|
<ColumnFilter column="title" label="Название" filterValue={filters['title'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('title')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'status') {
|
if (key === 'status') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="status" label="Статус" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('status')} />
|
<ColumnFilter column="status" label="Статус" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('status')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'assignedTo') {
|
if (key === 'assignedTo') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="assignedTo" label="Ответственный" filterValue={filters['assignedTo'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('assignedTo')} />
|
<ColumnFilter column="assignedTo" label="Ответственный" filterValue={filters['assignedTo'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('assignedTo')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'createdAt') {
|
if (key === 'createdAt') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="createdAt" label="Создана" filterValue={filters['createdAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdAt')} />
|
<ColumnFilter column="createdAt" label="Создана" filterValue={filters['createdAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdAt')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'dueDate') {
|
if (key === 'dueDate') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="dueDate" label="Срок" filterValue={filters['dueDate'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('dueDate')} />
|
<ColumnFilter column="dueDate" label="Срок" filterValue={filters['dueDate'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('dueDate')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'description') {
|
if (key === 'description') {
|
||||||
return <TableHead key={key}>Описание</TableHead>;
|
return <RegistryHead key={key} columnKey={key}>Описание</RegistryHead>;
|
||||||
}
|
}
|
||||||
if (key === 'updatedAt') {
|
if (key === 'updatedAt') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="updatedAt" label="Обновлена" filterValue={filters['updatedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('updatedAt')} />
|
<ColumnFilter column="updatedAt" label="Обновлена" filterValue={filters['updatedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('updatedAt')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'createdBy') {
|
if (key === 'createdBy') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="createdBy" label="Автор" filterValue={filters['createdBy'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdBy')} />
|
<ColumnFilter column="createdBy" label="Автор" filterValue={filters['createdBy'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdBy')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'completedAt') {
|
if (key === 'completedAt') {
|
||||||
return (
|
return (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column="completedAt" label="Завершена" filterValue={filters['completedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('completedAt')} />
|
<ColumnFilter column="completedAt" label="Завершена" filterValue={filters['completedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('completedAt')} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key.startsWith('field_')) {
|
if (key.startsWith('field_')) {
|
||||||
const fieldId = parseInt(key.replace('field_', ''));
|
const fieldId = parseInt(key.replace('field_', ''));
|
||||||
const field = fields.find(f => f.id === fieldId);
|
const field = fields.find(f => f.id === fieldId);
|
||||||
return field ? (
|
return field ? (
|
||||||
<TableHead key={key}>
|
<RegistryHead key={key} columnKey={key}>
|
||||||
<ColumnFilter column={key} label={field.name} filterValue={filters[key] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions(key)} />
|
<ColumnFilter column={key} label={field.name} filterValue={filters[key] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions(key)} />
|
||||||
</TableHead>
|
</RegistryHead>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1002,18 +1107,23 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
{columnOrder.filter(key => visibleColumns[key]).map(key => {
|
{columnOrder.filter(key => visibleColumns[key]).map(key => {
|
||||||
if (key === 'title') {
|
if (key === 'title') {
|
||||||
return (
|
return (
|
||||||
<TableCell key={key} className="py-1 font-medium max-w-xs">
|
<TableCell key={key} className={`py-1 font-medium ${columnWidths.title ? '' : 'max-w-xs'}`}>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className={`font-medium text-sm text-primary hover:text-primary/80 hover:underline text-left truncate block w-full ${isCompleted ? 'line-through text-muted-foreground' : ''}`}
|
className={`font-medium text-sm text-primary hover:text-primary/80 hover:underline text-left w-full block ${maxLines === 'auto' ? 'truncate' : ''} ${isCompleted ? 'line-through text-muted-foreground' : ''}`}
|
||||||
|
style={clampStyle(maxLines)}
|
||||||
title={task.title}
|
title={task.title}
|
||||||
data-testid={`button-task-title-${task.id}`}
|
data-testid={`button-task-title-${task.id}`}
|
||||||
onClick={() => onTaskClick?.(task)}
|
onClick={() => onTaskClick?.(task)}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</button>
|
</button>
|
||||||
{task.description && (
|
{task.description && (maxLines === 'auto' || maxLines >= 3) && (
|
||||||
<p className={`text-xs text-muted-foreground truncate mt-0.5 ${isCompleted ? 'line-through' : ''}`} title={task.description}>
|
<p
|
||||||
|
className={`text-xs text-muted-foreground mt-0.5 ${maxLines === 'auto' ? 'truncate' : ''} ${isCompleted ? 'line-through' : ''}`}
|
||||||
|
style={maxLines === 'auto' ? undefined : clampStyle(1)}
|
||||||
|
title={task.description}
|
||||||
|
>
|
||||||
{task.description}
|
{task.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1044,15 +1154,15 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
>
|
>
|
||||||
{(task.assignees && task.assignees.length > 0) ? (
|
{(task.assignees && task.assignees.length > 0) ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<User className="w-3 h-3 mr-1 text-muted-foreground" />
|
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm">
|
<span className="text-sm" style={clampStyle(maxLines)}>
|
||||||
{task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(', ')}
|
{task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : task.assignedTo ? (
|
) : task.assignedTo ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<User className="w-3 h-3 mr-1 text-muted-foreground" />
|
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm">{getUserName(task.assignedTo)}</span>
|
<span className="text-sm" style={clampStyle(maxLines)}>{getUserName(task.assignedTo)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
@@ -1094,9 +1204,13 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
}
|
}
|
||||||
if (key === 'description') {
|
if (key === 'description') {
|
||||||
return (
|
return (
|
||||||
<TableCell key={key} className="py-2 max-w-sm">
|
<TableCell key={key} className={`py-2 ${columnWidths.description ? '' : 'max-w-sm'}`}>
|
||||||
{task.description ? (
|
{task.description ? (
|
||||||
<p className="text-sm truncate" title={task.description}>
|
<p
|
||||||
|
className={`text-sm ${maxLines === 'auto' ? 'truncate' : ''}`}
|
||||||
|
style={clampStyle(maxLines)}
|
||||||
|
title={task.description}
|
||||||
|
>
|
||||||
{task.description}
|
{task.description}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -1119,8 +1233,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
return (
|
return (
|
||||||
<TableCell key={key} className="py-1">
|
<TableCell key={key} className="py-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<User className="w-3 h-3 mr-1 text-muted-foreground" />
|
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
|
||||||
<span className="text-sm">{getUserName(task.createdBy)}</span>
|
<span className="text-sm" style={clampStyle(maxLines)}>{getUserName(task.createdBy)}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
@@ -1155,7 +1269,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
) : REGISTRY_QUICK_TOGGLE_TYPES.has(field.type) ? (
|
) : REGISTRY_QUICK_TOGGLE_TYPES.has(field.type) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm cursor-pointer hover:text-primary"
|
className="text-sm cursor-pointer hover:text-primary text-left w-full"
|
||||||
|
style={clampStyle(maxLines)}
|
||||||
onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
|
onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
|
||||||
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
|
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
|
||||||
>
|
>
|
||||||
@@ -1165,7 +1280,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
<InlineField
|
<InlineField
|
||||||
ariaLabel={field.name}
|
ariaLabel={field.name}
|
||||||
readView={
|
readView={
|
||||||
<span className="text-sm">
|
<span className="text-sm" style={clampStyle(maxLines)}>
|
||||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1308,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
|||||||
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[24px] w-full'}
|
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">
|
<span className="text-sm" style={clampStyle(maxLines)}>
|
||||||
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { tableRecipe } from "@/design-system/recipes"
|
|||||||
|
|
||||||
const Table = React.forwardRef<
|
const Table = React.forwardRef<
|
||||||
HTMLTableElement,
|
HTMLTableElement,
|
||||||
React.HTMLAttributes<HTMLTableElement>
|
React.HTMLAttributes<HTMLTableElement> & { wrapperClassName?: string }
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, wrapperClassName, ...props }, ref) => (
|
||||||
<div className={tableRecipe.wrapper}>
|
<div className={cn(tableRecipe.wrapper, wrapperClassName)}>
|
||||||
<table
|
<table
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(tableRecipe.root, className)}
|
className={cn(tableRecipe.root, className)}
|
||||||
|
|||||||
Reference in New Issue
Block a user