Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/features/collections/components/TableView.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>('.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);
266 changes: 146 additions & 120 deletions src/features/collections/components/TableView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -51,6 +51,22 @@ const TableView: React.FC<TableViewProps> = ({
setSelectedRows,
}) => {
const theme = useTheme();
const [wrapText, setWrapText] = useState(() => {
try {
return localStorage.getItem('firestudio.table.wrapText') === 'true';
} catch {
return false;
}
});

const handleWrapTextChange = (_event: React.ChangeEvent<HTMLInputElement>, 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);

Expand Down Expand Up @@ -237,132 +253,142 @@ const TableView: React.FC<TableViewProps> = ({
const gridColumns = `40px ${getColWidth('__docId__')}px ${visibleFields.map((f) => `${getColWidth(f)}px`).join(' ')}`;

return (
<Box sx={{ flexGrow: 1, overflow: 'auto', position: 'relative', bgcolor: tableColors.rowBg }}>
{documents.length > MAX_VISIBLE_ROWS && (
<Box
sx={{
p: 0.5,
backgroundColor: theme.palette.warning.main,
color: theme.palette.warning.contrastText || '#000',
fontSize: '0.75rem',
textAlign: 'center',
fontWeight: 500,
<Box sx={{ flexGrow: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ px: 1.5, py: 0.25, borderBottom: cellBorder, bgcolor: tableColors.rowBg }}>
<FormControlLabel
control={<Switch size="small" checked={wrapText} onChange={handleWrapTextChange} />}
label="Wrap text"
sx={{ m: 0, '& .MuiFormControlLabel-label': { fontSize: '0.8rem' } }}
/>
</Box>
<Box sx={{ flexGrow: 1, minHeight: 0, overflow: 'auto', position: 'relative', bgcolor: tableColors.rowBg }}>
{documents.length > MAX_VISIBLE_ROWS && (
<Box
sx={{
p: 0.5,
backgroundColor: theme.palette.warning.main,
color: theme.palette.warning.contrastText || '#000',
fontSize: '0.75rem',
textAlign: 'center',
fontWeight: 500,
}}
>
Showing first {MAX_VISIBLE_ROWS} of {documents.length} rows for performance
</Box>
)}

<div
style={{
display: 'grid',
gridTemplateColumns: gridColumns,
fontSize: '0.8rem',
minWidth: 'max-content',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
Showing first {MAX_VISIBLE_ROWS} of {documents.length} rows for performance
</Box>
)}

<div
style={{
display: 'grid',
gridTemplateColumns: gridColumns,
fontSize: '0.8rem',
minWidth: 'max-content',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<TableHeaders
visibleFields={visibleFields}
allSelected={allSelected}
someSelected={someSelected}
onSelectAll={handleSelectAll}
onResizeStart={handleResizeStart}
cellBorder={cellBorder}
tableColors={tableColors}
/>

{displayedDocs.map((doc, rowIndex) => (
<TableRow
key={doc.id}
doc={doc}
rowIndex={rowIndex}
<TableHeaders
visibleFields={visibleFields}
selectedRows={selectedRows}
editingCell={editingCell}
selectedCell={selectedCell}
editValue={editValue}
setEditValue={setEditValue}
onSelectRow={handleSelectRow}
onCellEdit={handleSelectCell}
onCellSave={onCellSave}
onCellKeyDown={onCellKeyDown}
onCellDoubleClick={handleCellDoubleClick}
getType={getType}
getTypeColor={getTypeColor}
formatValue={formatValue}
tableColors={tableColors}
allSelected={allSelected}
someSelected={someSelected}
onSelectAll={handleSelectAll}
onResizeStart={handleResizeStart}
cellBorder={cellBorder}
setBoolMenuAnchor={setBoolMenuAnchor}
setBoolMenuData={setBoolMenuData}
setDateMenuAnchor={setDateMenuAnchor}
setDateMenuData={setDateMenuData}
setTempDateValue={setTempDateValue}
tableColors={tableColors}
/>
))}
</div>

{documents.length === 0 && (
<Box sx={{ p: 4, textAlign: 'center', color: 'text.secondary' }}>No documents found</Box>
)}

<EditDialog
open={editDialogOpen}
onClose={() => {
setEditDialogOpen(false);
setSelectedCell(null);
}}
onSave={handleDialogSave}
data={editDialogData}
setData={setEditDialogData}
/>

<BooleanPopover
anchorEl={boolMenuAnchor}
onClose={() => {
setBoolMenuAnchor(null);
onCellEdit(null, null, null); // Cancel
}}
onSelect={(val) => {
setBoolMenuAnchor(null);
onCellSave(boolMenuData.docId, boolMenuData.field, val);
}}
currentValue={editValue}
/>

<DatePopover
anchorEl={dateMenuAnchor}
onClose={() => {
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) => (
<TableRow
wrapText={wrapText}
key={doc.id}
doc={doc}
rowIndex={rowIndex}
visibleFields={visibleFields}
selectedRows={selectedRows}
editingCell={editingCell}
selectedCell={selectedCell}
editValue={editValue}
setEditValue={setEditValue}
onSelectRow={handleSelectRow}
onCellEdit={handleSelectCell}
onCellSave={onCellSave}
onCellKeyDown={onCellKeyDown}
onCellDoubleClick={handleCellDoubleClick}
getType={getType}
getTypeColor={getTypeColor}
formatValue={formatValue}
tableColors={tableColors}
cellBorder={cellBorder}
setBoolMenuAnchor={setBoolMenuAnchor}
setBoolMenuData={setBoolMenuData}
setDateMenuAnchor={setDateMenuAnchor}
setDateMenuData={setDateMenuData}
setTempDateValue={setTempDateValue}
/>
))}
</div>

{documents.length === 0 && (
<Box sx={{ p: 4, textAlign: 'center', color: 'text.secondary' }}>No documents found</Box>
)}

<EditDialog
open={editDialogOpen}
onClose={() => {
setEditDialogOpen(false);
setSelectedCell(null);
}}
onSave={handleDialogSave}
data={editDialogData}
setData={setEditDialogData}
/>

<BooleanPopover
anchorEl={boolMenuAnchor}
onClose={() => {
setBoolMenuAnchor(null);
onCellEdit(null, null, null); // Cancel
}}
onSelect={(val) => {
setBoolMenuAnchor(null);
onCellSave(boolMenuData.docId, boolMenuData.field, val);
}}
currentValue={editValue}
/>

<DatePopover
anchorEl={dateMenuAnchor}
onClose={() => {
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}
/>
</Box>
</Box>
);
};
Expand Down
6 changes: 4 additions & 2 deletions src/features/collections/components/table/CellRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +46,7 @@ interface CellRendererProps {
}

const CellRenderer: React.FC<CellRendererProps> = ({
wrapText,
docId,
field,
value,
Expand Down Expand Up @@ -128,7 +130,7 @@ const CellRenderer: React.FC<CellRendererProps> = ({
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',
Expand Down
Loading
Loading