/** * RowBinding Component * ADR-024: Chat & Message Architecture * * Universal row binding - bind chat to ANY row from ANY table * Replaces the old TaskSelector that was limited to "task tables" * * Features: * - Select from any table in space * - Support multiple bindings * - Inline display of bound items * - Quick unbind */ 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, Table2, Hash, Plus, Trash2, Unlink } from 'lucide-react'; interface TableInfo { id: number; name: string; slug?: string; icon?: string; display_column?: string; } interface RowInfo { id: number; table_id: number; data: Record; created_at?: string; } export interface BoundRow { table_id: number; row_id: number; table_name?: string; table_icon?: string; row_title?: string; } export interface RowBindingProps { spaceId?: number; defaultTableId?: number; boundRows?: BoundRow[]; maxBindings?: number; compact?: boolean; onBind: (tableId: number, rowId: number, meta?: { tableName: string; rowTitle: string }) => void; onUnbind: (tableId: number, rowId: number) => void; className?: string; } export function RowBinding({ spaceId, defaultTableId, boundRows = [], maxBindings = 10, compact = false, onBind, onUnbind, className }: RowBindingProps) { const [isExpanded, setIsExpanded] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedTableId, setSelectedTableId] = useState(defaultTableId ?? null); const [showTableSelector, setShowTableSelector] = useState(false); // Update selected table when defaultTableId changes useEffect(() => { if (defaultTableId && !selectedTableId) { setSelectedTableId(defaultTableId); } }, [defaultTableId]); // Fetch tables from space const { data: tables = [], isLoading: isLoadingTables } = useQuery({ queryKey: ['tables-for-binding', spaceId], queryFn: async () => { if (!spaceId) return []; const response = await apiClient.get<{ success: boolean; data: TableInfo[]; }>(`/tables?spaceId=${spaceId}`); return response.success ? response.data : []; }, enabled: !!spaceId && isExpanded }); // 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', '30'); const response = await apiClient.get<{ success: boolean; data: { rows: RowInfo[] }; }>(`/tables/${selectedTableId}/rows?${params}`); return response.success ? response.data.rows : []; }, enabled: !!selectedTableId && isExpanded }); const selectedTable = tables.find(t => t.id === selectedTableId); // Get display value for a row const getRowDisplayValue = (row: RowInfo, table?: TableInfo) => { const displayColumn = table?.display_column || 'name'; const data = row.data as Record; return String(data[displayColumn] || data['title'] || data['name'] || data['subject'] || `#${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 row selection const handleRowSelect = (row: RowInfo) => { if (isRowBound(row.table_id, row.id)) return; if (boundRows.length >= maxBindings) return; const table = tables.find(t => t.id === row.table_id); onBind(row.table_id, row.id, { tableName: table?.name || 'Unknown', rowTitle: getRowDisplayValue(row, table) }); // Keep panel open for multi-bind setSearchQuery(''); }; const handleTableChange = (table: TableInfo) => { setSelectedTableId(table.id); setShowTableSelector(false); setSearchQuery(''); }; 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 */} {/* Expanded Panel */} {isExpanded && (
{/* Bound Rows List */} {boundRows.length > 0 && (
Привязанные записи
{boundRows.map((br, idx) => (
{br.row_title || `#${br.row_id}`} {br.table_name && ( {br.table_name} )}
))}
)} {/* Add New Binding */} {canAddMore && spaceId && (
Добавить привязку
{/* Table Selector */}
{showTableSelector && (
{isLoadingTables ? (
) : tables.length === 0 ? (
Нет таблиц
) : ( tables.map(table => ( )) )}
)}
{/* Row Search & List */} {selectedTableId && ( <>
setSearchQuery(e.target.value)} placeholder="Поиск записей..." className="w-full pl-8 pr-3 py-2 text-sm rounded border border-[var(--border-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary-500)]" /> {searchQuery && ( )}
{isLoadingRows ? (
) : tableRows.length === 0 ? (
{searchQuery ? 'Не найдено' : 'Нет записей'}
) : ( tableRows.map(row => { const bound = isRowBound(row.table_id, row.id); return ( ); }) )}
)}
)} {/* Max bindings reached */} {!canAddMore && (
Достигнут лимит привязок ({maxBindings})
)} {/* No space selected */} {!spaceId && (
Выберите Space в настройках чата
)} {/* Close Button */}
)}
); }