/** * RowBindingV2 Component * ADR-024: Chat & Message Architecture * * Universal row binding using useAllTables hook: * - Project selector with optgroups (Space → Project) * - Table selector with IDs * - Row search and selection * - Auto-mapping to Tasks table if tasksSource is provided * - Space files binding if spaceFilesTableId is provided * * Simplified from previous 595-line cascading version. */ import { useState, useMemo, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/shared/utils/apiClient'; import { cn } from '@/shared/utils/cn'; import { Link2, Loader2, Search, X, ChevronDown, ChevronRight, Hash, Trash2, Check, Plus, File, FolderOpen } from 'lucide-react'; import { useAllTables } from '@/features/tables/hooks/useAllTables'; interface RowInfo { id: number; table_id: number; data: Record; created_at?: string; } export interface BoundRow { space_id?: number; project_id?: number; table_id: number; row_id: number; table_name?: string; table_icon?: string; row_title?: string; project_name?: string; } /** Tasks source config for auto-mapping */ export interface TasksSourceConfig { tableId: number; tableName: string; tableIcon?: string; displayColumn?: string; } export interface RowBindingV2Props { /** Current space ID (defaults to this space) */ defaultSpaceId?: number; defaultTableId?: number; boundRows?: BoundRow[]; maxBindings?: number; compact?: boolean; /** Allow selecting from other spaces */ allowCrossSpace?: boolean; /** Hide the header toggle button */ hideHeader?: boolean; /** Force expanded state (controlled from outside) */ forceExpanded?: boolean; /** Close handler for external control */ onClose?: () => void; onBind: (binding: BoundRow) => void; onUnbind: (tableId: number, rowId: number) => void; className?: string; /** Auto-mapping: Tasks source config - shows tasks list immediately */ tasksSource?: TasksSourceConfig; /** Auto-mapping: Space files table ID - shows files section */ spaceFilesTableId?: number; /** Allow selecting from other tables when tasksSource is set */ allowOtherTables?: boolean; } export function RowBindingV2({ defaultSpaceId, defaultTableId, boundRows = [], maxBindings = 10, compact = false, allowCrossSpace = false, hideHeader = false, forceExpanded, onClose, onBind, onUnbind, className, tasksSource, spaceFilesTableId, allowOtherTables = true }: RowBindingV2Props) { const [isExpandedInternal, setIsExpandedInternal] = useState(false); const isExpanded = forceExpanded !== undefined ? forceExpanded : isExpandedInternal; const setIsExpanded = (value: boolean) => { if (forceExpanded === undefined) setIsExpandedInternal(value); }; const [searchQuery, setSearchQuery] = useState(''); const [tasksSearchQuery, setTasksSearchQuery] = useState(''); const [filesSearchQuery, setFilesSearchQuery] = useState(''); // Selection state const [selectedProjectId, setSelectedProjectId] = useState(null); const [selectedTableId, setSelectedTableId] = useState( defaultTableId || null ); // Section expansion states const [showOtherTables, setShowOtherTables] = useState(false); const [showFilesSection, setShowFilesSection] = useState(false); // Load all tables hierarchy const { data: allTablesData, isLoading: isLoadingTables } = useAllTables(); // Update from defaults useEffect(() => { if (defaultTableId && allTablesData?.flat) { setSelectedTableId(defaultTableId); const table = allTablesData.flat.find(t => t.id === String(defaultTableId)); if (table) { setSelectedProjectId(table.projectId); } } }, [defaultTableId, allTablesData]); // Filter spaces (if cross-space disabled, show only default space) const filteredSpaces = useMemo(() => { if (!allTablesData?.spacesWithTables) return []; if (allowCrossSpace) return allTablesData.spacesWithTables; if (!defaultSpaceId) return allTablesData.spacesWithTables; return allTablesData.spacesWithTables.filter(s => s.id === defaultSpaceId); }, [allTablesData, allowCrossSpace, defaultSpaceId]); // Get tables for selected project const projectTables = useMemo(() => { if (!selectedProjectId || !allTablesData?.spacesWithTables) return []; for (const space of allTablesData.spacesWithTables) { const project = space.projects.find(p => p.id === selectedProjectId); if (project) { return project.tables || []; } } return []; }, [selectedProjectId, allTablesData]); // Get selected table info const selectedTableInfo = useMemo(() => { if (!selectedTableId || !allTablesData?.flat) return null; return allTablesData.flat.find(t => t.id === String(selectedTableId)); }, [selectedTableId, allTablesData]); // Get selected project name const selectedProjectName = useMemo(() => { if (!selectedProjectId || !allTablesData?.spacesWithTables) return null; for (const space of allTablesData.spacesWithTables) { const project = space.projects.find(p => p.id === selectedProjectId); if (project) return project.name; } return null; }, [selectedProjectId, allTablesData]); // Fetch rows from selected table const { data: tableRows = [], isLoading: isLoadingRows } = useQuery({ queryKey: ['rows-for-binding', selectedTableId, searchQuery], queryFn: async () => { if (!selectedTableId) return []; const params = new URLSearchParams(); if (searchQuery) params.append('search', searchQuery); params.append('limit', '50'); const response = await apiClient.get<{ success: boolean; data: { rows: RowInfo[] }; }>(`/tables/${selectedTableId}/rows?${params}`); return response.success ? response.data.rows : []; }, enabled: !!selectedTableId && isExpanded }); // Fetch tasks from configured tasks table (auto-mapping) const { data: tasksRows = [], isLoading: isLoadingTasks } = useQuery({ queryKey: ['tasks-for-binding', tasksSource?.tableId, tasksSearchQuery], queryFn: async () => { if (!tasksSource?.tableId) return []; const params = new URLSearchParams(); if (tasksSearchQuery) params.append('search', tasksSearchQuery); params.append('limit', '50'); const response = await apiClient.get<{ success: boolean; data: { rows: RowInfo[] }; }>(`/tables/${tasksSource.tableId}/rows?${params}`); return response.success ? response.data.rows : []; }, enabled: !!tasksSource?.tableId && isExpanded }); // Fetch files from space files table const { data: filesRows = [], isLoading: isLoadingFiles } = useQuery({ queryKey: ['files-for-binding', spaceFilesTableId, filesSearchQuery], queryFn: async () => { if (!spaceFilesTableId) return []; const params = new URLSearchParams(); if (filesSearchQuery) params.append('search', filesSearchQuery); params.append('limit', '50'); const response = await apiClient.get<{ success: boolean; data: { rows: RowInfo[] }; }>(`/tables/${spaceFilesTableId}/rows?${params}`); return response.success ? response.data.rows : []; }, enabled: !!spaceFilesTableId && isExpanded && showFilesSection }); // Get display value for a row const getRowDisplayValue = (row: RowInfo, displayField?: string) => { const data = row.data as Record; return String( data[selectedTableInfo?.displayField || 'name'] || data['title'] || data['name'] || data['subject'] || `#${row.id}` ); }; // Get display value for a task row const getTaskDisplayValue = (row: RowInfo) => { const data = row.data as Record; return String( data[tasksSource?.displayColumn || 'title'] || data['title'] || data['name'] || `#${row.id}` ); }; // Get display value for a file row const getFileDisplayValue = (row: RowInfo) => { const data = row.data as Record; return String( data['name'] || data['filename'] || data['title'] || `#${row.id}` ); }; // Check if row is already bound const isRowBound = (tableId: number, rowId: number) => { return boundRows.some(br => br.table_id === tableId && br.row_id === rowId); }; // Handle project change const handleProjectChange = (projectId: string) => { const newProjectId = projectId ? Number(projectId) : null; setSelectedProjectId(newProjectId); setSelectedTableId(null); // Reset table setSearchQuery(''); }; // Handle table change const handleTableChange = (tableId: string) => { setSelectedTableId(tableId ? Number(tableId) : null); setSearchQuery(''); }; // Handle row selection const handleRowSelect = (row: RowInfo) => { if (isRowBound(row.table_id, row.id)) return; if (boundRows.length >= maxBindings) return; onBind({ space_id: selectedTableInfo?.spaceId, project_id: selectedProjectId ?? undefined, table_id: row.table_id, row_id: row.id, table_name: selectedTableInfo?.displayName || selectedTableInfo?.name, table_icon: selectedTableInfo?.icon, row_title: getRowDisplayValue(row), project_name: selectedProjectName ?? undefined }); // Reset search for next selection setSearchQuery(''); }; // Handle "add more from same table" - auto-map to table const handleAddMoreFromTable = (tableId: number, projectId?: number) => { if (projectId) { setSelectedProjectId(projectId); } setSelectedTableId(tableId); setSearchQuery(''); }; // Handle task selection (from auto-mapped tasks table) const handleTaskSelect = (row: RowInfo) => { if (!tasksSource) return; if (isRowBound(tasksSource.tableId, row.id)) return; if (boundRows.length >= maxBindings) return; onBind({ table_id: tasksSource.tableId, row_id: row.id, table_name: tasksSource.tableName, table_icon: tasksSource.tableIcon || '📋', row_title: getTaskDisplayValue(row) }); setTasksSearchQuery(''); }; // Handle file selection (from space files table) const handleFileSelect = (row: RowInfo) => { if (!spaceFilesTableId) return; if (isRowBound(spaceFilesTableId, row.id)) return; if (boundRows.length >= maxBindings) return; onBind({ table_id: spaceFilesTableId, row_id: row.id, table_name: 'Файлы пространства', table_icon: '📁', row_title: getFileDisplayValue(row) }); setFilesSearchQuery(''); }; const canAddMore = boundRows.length < maxBindings; // Compact mode - just show bound items inline if (compact && boundRows.length > 0 && !isExpanded) { return (
{boundRows.map((br, idx) => (
{br.row_title || `#${br.row_id}`}
))} {canAddMore && ( )}
); } return (
{/* Header - Toggle (hidden if hideHeader) */} {!hideHeader && ( )} {/* Expanded Content */} {isExpanded && (
{/* Bound rows list */} {boundRows.length > 0 && (
Привязанные записи ({boundRows.length}/{maxBindings}) {onClose && ( )}
{boundRows.map((br, idx) => (
{br.table_icon || '📋'} {/* Breadcrumbs: project → table → row */}
{br.project_name && ( <> {br.project_name} )} {br.table_name && ( <> {br.table_name} )} {br.row_title || `#${br.row_id}`}
{/* Add more from same table */} {canAddMore && ( )}
))}
)} {/* Selection Section */} {canAddMore && (
{isLoadingTables ? (
) : ( <> {/* === Quick Tasks Section (if tasksSource configured) === */} {tasksSource && (
{/* Tasks header */}
{tasksSource.tableIcon || '📋'} {tasksSource.tableName}
{/* Tasks search */}
setTasksSearchQuery(e.target.value)} placeholder="Поиск задач..." className="w-full pl-8 pr-8 py-2 text-sm rounded-lg border border-[var(--border-primary)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30" /> {tasksSearchQuery && ( )}
{/* Tasks list */}
{isLoadingTasks ? (
) : tasksRows.length === 0 ? (
{tasksSearchQuery ? 'Не найдено' : 'Нет задач'}
) : ( tasksRows.map(row => { const bound = isRowBound(tasksSource.tableId, row.id); return ( ); }) )}
)} {/* === Space Files Section (if spaceFilesTableId configured) === */} {spaceFilesTableId && (
{/* Files header - clickable to expand */} {/* Files content (expanded) */} {showFilesSection && (
{/* Files search */}
setFilesSearchQuery(e.target.value)} placeholder="Поиск файлов..." className="w-full pl-8 pr-8 py-2 text-sm rounded-lg border border-[var(--border-primary)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30" /> {filesSearchQuery && ( )}
{/* Files list */}
{isLoadingFiles ? (
) : filesRows.length === 0 ? (
{filesSearchQuery ? 'Не найдено' : 'Нет файлов'}
) : ( filesRows.map(row => { const bound = isRowBound(spaceFilesTableId, row.id); return ( ); }) )}
)}
)} {/* === Other Tables Section === */} {(allowOtherTables || (!tasksSource && !spaceFilesTableId)) && (
{/* Header - clickable if tasksSource is set */} {tasksSource ? ( ) : null} {/* Content - always shown if no tasksSource, or when expanded */} {(!tasksSource || showOtherTables) && (
{/* Project selector with optgroups by Space */}
{/* Table selector */}
{/* Selected table indicator */} {selectedTableInfo && (
{selectedTableInfo.icon || '📋'} {selectedTableInfo.displayName} (ID: {selectedTableInfo.id})
)} {/* Row search and selection */} {selectedTableId && (
{/* Search */}
setSearchQuery(e.target.value)} placeholder="Поиск записей..." className="w-full pl-8 pr-8 py-2 text-sm rounded-lg border border-[var(--border-primary)] bg-[var(--bg-tertiary)] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30" /> {searchQuery && ( )}
{/* Rows list */}
{isLoadingRows ? (
) : tableRows.length === 0 ? (
{searchQuery ? 'Не найдено' : 'Нет записей'}
) : ( tableRows.map(row => { const bound = isRowBound(row.table_id, row.id); return ( ); }) )}
)}
)}
)} )}
)} {/* Max bindings reached */} {!canAddMore && (
Достигнут лимит привязок ({maxBindings})
)}
)}
); }