From 7ab57db401f283e3c21627054a13a26c8f553187 Mon Sep 17 00:00:00 2001 From: Clarence Cheung Date: Tue, 15 Sep 2026 02:28:05 -0700 Subject: [PATCH] feat: add persistent text wrapping in table view --- .../collections/components/TableView.test.ts | 62 ++++ .../collections/components/TableView.tsx | 266 ++++++++++-------- .../components/table/CellRenderer.tsx | 6 +- .../collections/components/table/TableRow.tsx | 7 +- src/shared/ui/textStyles.ts | 9 + 5 files changed, 226 insertions(+), 124 deletions(-) create mode 100644 src/features/collections/components/TableView.test.ts diff --git a/src/features/collections/components/TableView.test.ts b/src/features/collections/components/TableView.test.ts new file mode 100644 index 0000000..3e7d773 --- /dev/null +++ b/src/features/collections/components/TableView.test.ts @@ -0,0 +1,62 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ThemeProvider } from '@mui/material'; +import { createAppTheme } from '../../../app/theme'; +import { formatDisplayValue, getTypeColor, getValueType } from '../../../shared/utils/firestoreUtils'; +import TableView from './TableView'; + +it('wraps complete values without saving data and remembers the preference', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const container = document.createElement('div'); + document.body.appendChild(container); + const longValue = 'First line\n' + 'https://example.com/' + 'long-text'.repeat(80); + const onCellSave = vi.fn(); + const props = { + documents: [{ id: 'document-id', data: { text: longValue, nested: { text: longValue } } }], + visibleFields: ['text', 'nested'], + editingCell: null, + editValue: '', + setEditValue: vi.fn(), + onCellEdit: vi.fn(), + onCellSave, + onCellKeyDown: vi.fn(), + columnWidths: {}, + setColumnWidths: vi.fn(), + getType: getValueType, + getTypeColor, + formatValue: formatDisplayValue, + selectedRows: [], + setSelectedRows: vi.fn(), + }; + const renderTable = () => + React.createElement( + ThemeProvider, + { theme: createAppTheme('light', 'medium') }, + React.createElement(TableView, props), + ); + let root = createRoot(container); + try { + await act(async () => root.render(renderTable())); + const cell = () => Array.from(container.querySelectorAll('div')).find((el) => el.title === longValue)!; + expect(cell().textContent).toBe(longValue); + expect(cell().style.whiteSpace).toBe('nowrap'); + const toggle = () => container.querySelector('.MuiSwitch-input')!; + await act(async () => toggle().click()); + expect(cell().style.whiteSpace).toBe('pre-wrap'); + expect(cell().style.overflowWrap).toBe('anywhere'); + expect(container.textContent).toContain(JSON.stringify({ text: longValue })); + expect(localStorage.getItem('firestudio.table.wrapText')).toBe('true'); + await act(async () => root.unmount()); + root = createRoot(container); + await act(async () => root.render(renderTable())); + expect(toggle().checked).toBe(true); + expect(cell().style.whiteSpace).toBe('pre-wrap'); + await act(async () => toggle().click()); + expect(cell().style.whiteSpace).toBe('nowrap'); + expect(onCellSave).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } +}, 15000); diff --git a/src/features/collections/components/TableView.tsx b/src/features/collections/components/TableView.tsx index 9d1b03b..3867e04 100644 --- a/src/features/collections/components/TableView.tsx +++ b/src/features/collections/components/TableView.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Box, useTheme } from '@mui/material'; +import { Box, FormControlLabel, Switch, useTheme } from '@mui/material'; import { isFirestoreTimestamp, isUnixTimestampMs } from '../../../shared/utils/dateUtils'; import { FirestoreValue } from '../../../shared/utils/firestoreUtils'; import { Document } from '../store/collectionSlice'; @@ -51,6 +51,22 @@ const TableView: React.FC = ({ setSelectedRows, }) => { const theme = useTheme(); + const [wrapText, setWrapText] = useState(() => { + try { + return localStorage.getItem('firestudio.table.wrapText') === 'true'; + } catch { + return false; + } + }); + + const handleWrapTextChange = (_event: React.ChangeEvent, checked: boolean) => { + setWrapText(checked); + try { + localStorage.setItem('firestudio.table.wrapText', String(checked)); + } catch { + // Wrapping remains available when preference storage is unavailable. + } + }; const resizingRef = useRef<{ field: string; startX: number; startWidth: number } | null>(null); const [selectedCell, setSelectedCell] = useState<{ docId: string; field: string } | null>(null); @@ -237,132 +253,142 @@ const TableView: React.FC = ({ const gridColumns = `40px ${getColWidth('__docId__')}px ${visibleFields.map((f) => `${getColWidth(f)}px`).join(' ')}`; return ( - - {documents.length > MAX_VISIBLE_ROWS && ( - + + } + label="Wrap text" + sx={{ m: 0, '& .MuiFormControlLabel-label': { fontSize: '0.8rem' } }} + /> + + + {documents.length > MAX_VISIBLE_ROWS && ( + + Showing first {MAX_VISIBLE_ROWS} of {documents.length} rows for performance + + )} + +
- Showing first {MAX_VISIBLE_ROWS} of {documents.length} rows for performance - - )} - -
- - - {displayedDocs.map((doc, rowIndex) => ( - - ))} -
- - {documents.length === 0 && ( - No documents found - )} - - { - setEditDialogOpen(false); - setSelectedCell(null); - }} - onSave={handleDialogSave} - data={editDialogData} - setData={setEditDialogData} - /> - - { - setBoolMenuAnchor(null); - onCellEdit(null, null, null); // Cancel - }} - onSelect={(val) => { - setBoolMenuAnchor(null); - onCellSave(boolMenuData.docId, boolMenuData.field, val); - }} - currentValue={editValue} - /> - - { - setDateMenuAnchor(null); - onCellEdit(null, null, null); - }} - onSelect={(val) => { - let finalValue: FirestoreValue = val; - const dateObj = new Date(val); - - if (!isNaN(dateObj.getTime())) { - const original = dateMenuData.originalValue; - if (isFirestoreTimestamp(original)) { - const seconds = Math.floor(dateObj.getTime() / 1000); - const nanoseconds = (dateObj.getTime() % 1000) * 1000000; - if (original._seconds !== undefined) { - finalValue = { _seconds: seconds, _nanoseconds: nanoseconds }; - } else { - finalValue = { seconds: seconds, nanoseconds: nanoseconds }; + + {displayedDocs.map((doc, rowIndex) => ( + + ))} +
+ + {documents.length === 0 && ( + No documents found + )} + + { + setEditDialogOpen(false); + setSelectedCell(null); + }} + onSave={handleDialogSave} + data={editDialogData} + setData={setEditDialogData} + /> + + { + setBoolMenuAnchor(null); + onCellEdit(null, null, null); // Cancel + }} + onSelect={(val) => { + setBoolMenuAnchor(null); + onCellSave(boolMenuData.docId, boolMenuData.field, val); + }} + currentValue={editValue} + /> + + { + setDateMenuAnchor(null); + onCellEdit(null, null, null); + }} + onSelect={(val) => { + let finalValue: FirestoreValue = val; + const dateObj = new Date(val); + + if (!isNaN(dateObj.getTime())) { + const original = dateMenuData.originalValue; + if (isFirestoreTimestamp(original)) { + const seconds = Math.floor(dateObj.getTime() / 1000); + const nanoseconds = (dateObj.getTime() % 1000) * 1000000; + if (original._seconds !== undefined) { + finalValue = { _seconds: seconds, _nanoseconds: nanoseconds }; + } else { + finalValue = { seconds: seconds, nanoseconds: nanoseconds }; + } + } else if (isUnixTimestampMs(original)) { + finalValue = dateObj.getTime(); + } else if (original instanceof Date) { + finalValue = dateObj; } - } else if (isUnixTimestampMs(original)) { - finalValue = dateObj.getTime(); - } else if (original instanceof Date) { - finalValue = dateObj; } - } - - setDateMenuAnchor(null); - onCellSave(dateMenuData.docId, dateMenuData.field, finalValue); - }} - initialValue={tempDateValue} - originalValue={dateMenuData.originalValue} - /> + + setDateMenuAnchor(null); + onCellSave(dateMenuData.docId, dateMenuData.field, finalValue); + }} + initialValue={tempDateValue} + originalValue={dateMenuData.originalValue} + /> +
); }; diff --git a/src/features/collections/components/table/CellRenderer.tsx b/src/features/collections/components/table/CellRenderer.tsx index e8a7083..cc445d0 100644 --- a/src/features/collections/components/table/CellRenderer.tsx +++ b/src/features/collections/components/table/CellRenderer.tsx @@ -10,9 +10,10 @@ import { import { MONOSPACE_FONT_FAMILY } from '../../../../shared/utils/constants'; import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; import { TableThemeColors } from '../../../../app/theme'; -import { singleLineTruncation } from '../../../../shared/ui/textStyles'; +import { singleLineTruncation, wrappedCellText } from '../../../../shared/ui/textStyles'; interface CellRendererProps { + wrapText: boolean; docId: string; field: string; value: FirestoreValue; @@ -45,6 +46,7 @@ interface CellRendererProps { } const CellRenderer: React.FC = ({ + wrapText, docId, field, value, @@ -128,7 +130,7 @@ const CellRenderer: React.FC = ({ padding: '6px 8px', borderBottom: cellBorder, borderRight: cellBorder, - ...singleLineTruncation, + ...(wrapText ? wrappedCellText : singleLineTruncation), color: value === undefined ? (isDark ? '#6b6b6b' : '#a0a0a0') : getTypeColor(type, isDark), fontStyle: value === undefined ? 'italic' : 'normal', fontFamily: type === 'Array' || type === 'Map' || type === 'String' ? MONOSPACE_FONT_FAMILY : 'inherit', diff --git a/src/features/collections/components/table/TableRow.tsx b/src/features/collections/components/table/TableRow.tsx index 4a075ef..66f1602 100644 --- a/src/features/collections/components/table/TableRow.tsx +++ b/src/features/collections/components/table/TableRow.tsx @@ -5,9 +5,10 @@ import CellRenderer from './CellRenderer'; import { FirestoreValue } from '../../../../shared/utils/firestoreUtils'; import { Document } from '../../store/collectionSlice'; import { TableThemeColors } from '../../../../app/theme'; -import { singleLineTruncation } from '../../../../shared/ui/textStyles'; +import { singleLineTruncation, wrappedCellText } from '../../../../shared/ui/textStyles'; interface TableRowProps { + wrapText: boolean; doc: Document; // Firestore document with id and data rowIndex: number; visibleFields: string[]; @@ -40,6 +41,7 @@ interface TableRowProps { } const TableRow: React.FC = ({ + wrapText, doc, rowIndex, visibleFields, @@ -117,7 +119,7 @@ const TableRow: React.FC = ({ fontSize: '0.75rem', borderBottom: cellBorder, borderRight: cellBorder, - ...singleLineTruncation, + ...(wrapText ? wrappedCellText : singleLineTruncation), backgroundColor: rowBg, transition: 'background-color 0.1s ease', }} @@ -148,6 +150,7 @@ const TableRow: React.FC = ({ return (