Spaces:
Sleeping
Sleeping
Download frontend/src/components/TableCellView.tsx from Sid-WC121/visual-notebook: direct link, hf CLI and curl.
- Browser
- Download file 8.69 kB
-
https://huggingface.co/spaces/Sid-WC121/visual-notebook/resolve/main/frontend/src/components/TableCellView.tsx
- Command line
-
hf download hf://spaces/Sid-WC121/visual-notebook/frontend/src/components/TableCellView.tsx
-
curl -L -o TableCellView.tsx https://huggingface.co/spaces/Sid-WC121/visual-notebook/resolve/main/frontend/src/components/TableCellView.tsx
8.69 kB
| import React, { useEffect, useState, useRef, useCallback } from 'react'; | |
| import { useRows, useEditCell } from '../api/hooks'; | |
| import { useSessionStore } from '../store/session'; | |
| import { formatCellValue, typeBadgeClass, typeLabel } from '../lib/format'; | |
| interface Props { | |
| cellId: string; | |
| config: Record<string, any>; | |
| } | |
| export function TableCellView({ cellId, config }: Props) { | |
| const { columns } = useSessionStore(); | |
| const { fetch: fetchRows, loading, error } = useRows(); | |
| const { edit: editCell, loading: editing } = useEditCell(); | |
| const [rows, setRows] = useState<Record<string, any>[]>([]); | |
| const [colNames, setColNames] = useState<string[]>([]); | |
| const [offset, setOffset] = useState(0); | |
| const [total, setTotal] = useState(0); | |
| const [editMode, setEditMode] = useState(false); | |
| const limit = 50; | |
| // Inline editing state | |
| const [editingCell, setEditingCell] = useState<{ rowIdx: number; colName: string } | null>(null); | |
| const [editValue, setEditValue] = useState<string>(''); | |
| const editInputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| loadData(); | |
| }, [offset]); | |
| useEffect(() => { | |
| if (editingCell && editInputRef.current) { | |
| editInputRef.current.focus(); | |
| editInputRef.current.select(); | |
| } | |
| }, [editingCell]); | |
| const loadData = async () => { | |
| const data = await fetchRows(offset, limit); | |
| if (data) { | |
| setRows(data.rows); | |
| setColNames(data.columns); | |
| setTotal(data.total); | |
| } | |
| }; | |
| const handleDoubleClick = useCallback((rowIdx: number, colName: string, currentValue: any) => { | |
| if (!editMode) return; | |
| setEditingCell({ rowIdx, colName }); | |
| setEditValue(currentValue == null ? '' : String(currentValue)); | |
| }, [editMode]); | |
| const handleEditSubmit = useCallback(async () => { | |
| if (!editingCell) return; | |
| const { rowIdx, colName } = editingCell; | |
| try { | |
| const result = await editCell(rowIdx, colName, editValue); | |
| if (result) { | |
| // Refresh data after successful edit | |
| await loadData(); | |
| } | |
| } catch { | |
| // Error handled by hook | |
| } | |
| setEditingCell(null); | |
| setEditValue(''); | |
| }, [editingCell, editValue, editCell]); | |
| const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| handleEditSubmit(); | |
| } else if (e.key === 'Escape') { | |
| setEditingCell(null); | |
| setEditValue(''); | |
| } | |
| }, [handleEditSubmit]); | |
| const totalPages = Math.ceil(total / limit); | |
| const currentPage = Math.floor(offset / limit) + 1; | |
| return ( | |
| <div> | |
| {loading && rows.length === 0 ? ( | |
| <div className="text-center py-8 text-gray-400"> | |
| <div className="animate-spin w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full mx-auto mb-2" /> | |
| Loading data... | |
| </div> | |
| ) : error ? ( | |
| <div className="text-red-500 text-sm">{error}</div> | |
| ) : ( | |
| <> | |
| {/* Table toolbar with edit mode toggle */} | |
| <div className="flex items-center justify-between mb-2"> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => { | |
| setEditMode(!editMode); | |
| if (editMode) { | |
| setEditingCell(null); | |
| setEditValue(''); | |
| } | |
| }} | |
| className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${ | |
| editMode | |
| ? 'bg-brand-100 text-brand-700 ring-1 ring-brand-300' | |
| : 'bg-gray-100 text-gray-500 hover:bg-gray-200 hover:text-gray-700' | |
| }`} | |
| title={editMode ? 'Disable editing' : 'Enable editing'} | |
| > | |
| <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> | |
| </svg> | |
| {editMode ? 'Editing' : 'Edit'} | |
| </button> | |
| {editMode && ( | |
| <span className="edit-mode-hint"> | |
| <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| </svg> | |
| Double-click a cell to edit | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="overflow-x-auto max-h-[500px] overflow-y-auto rounded-lg border border-gray-200"> | |
| <table className="data-table w-full"> | |
| <thead> | |
| <tr> | |
| <th className="w-10 text-center">#</th> | |
| {colNames.map((name) => { | |
| const col = columns.find((c) => c.name === name); | |
| return ( | |
| <th key={name}> | |
| <div className="flex items-center gap-1"> | |
| <span className={`badge ${typeBadgeClass(col?.column_type || 'unknown')} text-[9px]`}> | |
| {typeLabel(col?.column_type || 'unknown')} | |
| </span> | |
| <span className="truncate max-w-[150px]" title={name}>{name}</span> | |
| </div> | |
| </th> | |
| ); | |
| })} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {rows.map((row, i) => { | |
| const globalRowIdx = offset + i; | |
| return ( | |
| <tr key={i}> | |
| <td className="text-center text-gray-400 text-xs font-mono">{globalRowIdx + 1}</td> | |
| {colNames.map((name) => { | |
| const isEditing = editingCell?.rowIdx === globalRowIdx && editingCell?.colName === name; | |
| const cellValue = row[name]; | |
| if (isEditing) { | |
| return ( | |
| <td key={name} className="p-0 editing-active"> | |
| <input | |
| ref={editInputRef} | |
| type="text" | |
| className="w-full px-3 py-2 text-sm border-2 border-brand-500 outline-none bg-white" | |
| value={editValue} | |
| onChange={(e) => setEditValue(e.target.value)} | |
| onBlur={handleEditSubmit} | |
| onKeyDown={handleEditKeyDown} | |
| disabled={editing} | |
| /> | |
| </td> | |
| ); | |
| } | |
| return ( | |
| <td | |
| key={name} | |
| className={editMode ? 'editable' : ''} | |
| onDoubleClick={() => handleDoubleClick(globalRowIdx, name, cellValue)} | |
| title={editMode ? 'Double-click to edit' : undefined} | |
| > | |
| {formatCellValue(cellValue)} | |
| </td> | |
| ); | |
| })} | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="flex items-center justify-between mt-3"> | |
| <span className="text-xs text-gray-500"> | |
| {offset + 1}-{Math.min(offset + limit, total)} of {total.toLocaleString()} rows | |
| </span> | |
| <div className="flex items-center gap-1"> | |
| <button | |
| onClick={() => setOffset(Math.max(0, offset - limit))} | |
| disabled={offset === 0} | |
| className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-30" | |
| > | |
| Prev | |
| </button> | |
| <span className="text-xs text-gray-500 px-2"> | |
| Page {currentPage} / {totalPages} | |
| </span> | |
| <button | |
| onClick={() => setOffset(offset + limit)} | |
| disabled={offset + limit >= total} | |
| className="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-30" | |
| > | |
| Next | |
| </button> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| } | |