Skip to content

Commit

Permalink
Reactive column widths
Browse files Browse the repository at this point in the history
  • Loading branch information
ajthinking committed Jan 22, 2025
1 parent 761426a commit f424a5e
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 128 deletions.
34 changes: 34 additions & 0 deletions packages/core/src/utils/isJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const isJson = (str: string) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};

export const isJsonArray = (str: string) => {
try {
const result = JSON.parse(str);
if (!Array.isArray(result)) {
return false;
}

return true;
} catch (e) {
return false;
}
};

export const isJsonObject = (str: string) => {
try {
const result = JSON.parse(str);
if (typeof result !== 'object') {
return false;
}

return true;
} catch (e) {
return false;
}
};
5 changes: 3 additions & 2 deletions packages/ui/src/components/Node/table/MemoizedTableBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ export const MemoizedTableBody = memo(({
/>
{virtualColumns.map((virtualColumn) => {
const cell = row.getVisibleCells()[virtualColumn.index];
const columnWidth = calculateColumnWidth(cell);
const maxChars = cell.column.columnDef.meta?.maxChars ?? 0;
const width = maxChars * 8 + 24; // 8px per character + 24px padding
return (
<td
key={cell.id}
className="whitespace-nowrap text-left"
style={{
display: 'flex',
position: 'relative',
width: `${columnWidth}px`,
width: `${width}px`,
height: `${FIXED_HEIGHT}px`,
}}
>
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/components/Node/table/MemoizedTableHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { memo } from 'react';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { VirtualItem } from '@tanstack/react-virtual';
import { calculateColumnWidth } from './TableCell';

export const MemoizedTableHeader = memo(({
headerGroups,
Expand Down Expand Up @@ -33,7 +32,8 @@ export const MemoizedTableHeader = memo(({
{
virtualColumns.map((virtualColumn) => {
const headerColumn = headerGroup.headers[virtualColumn.index];
const columnWidth = calculateColumnWidth(headerColumn);
const maxChars = headerColumn.column.columnDef.meta?.maxChars ?? 0;
const width = maxChars * 8 + 24; // 8px per character + 24px padding

return (
<th
Expand All @@ -42,7 +42,7 @@ export const MemoizedTableHeader = memo(({
style={{
display: 'flex',
position: 'relative',
width: `${columnWidth}px`,
width: `${width}px`,
}}
className="whitespace-nowrap bg-gray-200 text-left border-r-0.5 last:border-r-0 border-gray-300"
>
Expand Down
27 changes: 6 additions & 21 deletions packages/ui/src/components/Node/table/TableCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,16 @@ export interface ColumnWidthInfo {
isNumeric: boolean;
}

export const calculateColumnWidth = (cell: Header<Record<string, unknown>, unknown>|Cell<Record<string, unknown>, unknown>) => {
// Get the header name
// @ts-ignore
const header = cell.column?.columnDef?.accessorKey as string || '';

// Safely get width info
export const calculateColumnWidth = (cell: Header<Record<string, unknown>, unknown> | Cell<Record<string, unknown>, unknown>) => {
const columnDef = cell.column?.columnDef as any;
const widthInfo = columnDef?.widthInfo as ColumnWidthInfo | undefined;

if (!widthInfo) {
// Fallback to header-based width if no width info
return Math.min(Math.max(header.length * 8, MIN_WIDTH), MAX_WIDTH);
}
const maxChars = columnDef?.maxChars as number;

if (widthInfo.isNumeric) {
// For numeric columns, use a more compact width
return Math.min(Math.max(widthInfo.maxContent * 8, MIN_WIDTH), 100);
if (!maxChars) {
return MIN_WIDTH;
}

// For text columns, use a weighted average of header and content width
const headerWidth = header.length * 8;
const contentWidth = Math.min(widthInfo.averageContent * 8, MAX_WIDTH);
const width = Math.max(headerWidth, contentWidth);

return Math.min(Math.max(width, MIN_WIDTH), MAX_WIDTH);
// Convert chars to pixels (8px per char)
return Math.min(Math.max(maxChars * 8, MIN_WIDTH), MAX_WIDTH);
}

const formatCellContent = (content: unknown) => {
Expand Down
209 changes: 107 additions & 102 deletions packages/ui/src/components/Node/table/TableNodeComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useObserverTable } from './UseObserverTable';
import CustomHandle from '../CustomHandle';
import { ItemValue, ItemWithParams } from '@data-story/core';
import { LoadingComponent } from './LoadingComponent';
import { FIXED_HEIGHT, MAX_WIDTH, MIN_WIDTH, TableCell } from './TableCell';
import { FIXED_HEIGHT, TableCell } from './TableCell';
import { MemoizedTableBody } from './MemoizedTableBody';
import { MemoizedTableHeader } from './MemoizedTableHeader';

Expand Down Expand Up @@ -47,91 +47,98 @@ const TableNodeComponent = ({ id, data }: {

useObserverTable({ id, setIsDataFetched, setItems, items, parentRef });

const input = data.inputs[0];

// Step 1: Calculate headers and rows once
const { headers, rows } = useMemo(() => {
const { only, drop } = getFormatterOnlyAndDropParam(items, data);
const itemCollection = new ItemCollection(items);
return itemCollection.toTable({only, drop });
}, [data.params, items]);
}, [items, data]);

const input = data.inputs[0];

const tableData = useMemo(() =>
rows.map((row) => {
const rowData = {};
// Step 2: Transform rows to table data once
const tableData = useMemo(() => {
return rows.map((row) => {
const rowData: Record<string, unknown> = {};
headers.forEach((header, index) => {
rowData[header] = row[index];
});
return rowData;
}),
[rows, headers]);

const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
() =>
headers.map((header) => {
// Calculate width info for this column
const columnData = tableData.map(row => row[header]);
const lengths = columnData.map(value => {
if (value === null || value === undefined) return 0;
return value.toString().length;
});

const isNumeric = columnData.every(value =>
typeof value === 'number' ||
(typeof value === 'string' && !isNaN(Number(value)))
);

const widthInfo = {
minContent: lengths.length ? Math.min(...lengths) : 0,
maxContent: lengths.length ? Math.max(...lengths) : 0,
averageContent: lengths.length ? lengths.reduce((a, b) => a + b, 0) / lengths.length : 0,
isNumeric
};

return {
accessorKey: header,
id: header,
header: () => <TableCell tableRef={tableRef} content={header}/>,
cell: ({ cell, row }) => {
const originalContent = row.original[cell.column?.id];
return <TableCell tableRef={tableRef} content={originalContent}/>
},
widthInfo
};
}), [headers, tableData]);

const tableInstance = useReactTable({
});
}, [rows, headers]);

// Step 3: Calculate column metadata once
const columnMetadata = useMemo(() => {
const metadata: Record<string, { maxChars: number }> = {};

headers.forEach(header => {
const columnData = tableData.map(row => row[header]);
const lengths = columnData.map(value => {
if (value === null || value === undefined) return 0;
return String(value).length;
});

metadata[header] = {
maxChars: Math.max(header.length, ...lengths)
};
});

return metadata;
}, [headers, tableData]);

// Create stable cell renderer
const cellRenderer = useCallback(({ row, column }: { row: any, column: any }) => {
const content = row.original[column.id as string];
return <TableCell tableRef={tableRef} content={content}/>;
}, [tableRef]);

const headerRenderer = useCallback((header: string) => {
return <TableCell tableRef={tableRef} content={header}/>;
}, [tableRef]);

// Step 4: Create column definitions with stable references
const columns = useMemo(() =>
headers.map((header): ColumnDef<Record<string, unknown>> => ({
accessorKey: header,
id: header,
header: () => headerRenderer(header),
cell: cellRenderer,
meta: {
maxChars: columnMetadata[header].maxChars
}
})),[headers, columnMetadata, headerRenderer, cellRenderer]);

// Step 5: Create table instance with minimal dependencies and stable options
const tableOptions = useMemo(() => ({
data: tableData,
columns,
defaultColumn: {
minSize: MIN_WIDTH,
maxSize: MAX_WIDTH,
},
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
debugTable: true
}), [tableData, columns]);

const tableInstance = useReactTable(tableOptions);

const { getHeaderGroups, getRowModel } = tableInstance;
const visibleColumns = tableInstance.getVisibleLeafColumns();

const rowVirtualizer = useVirtualizer({
count: getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => FIXED_HEIGHT, // every row fixed height
estimateSize: () => FIXED_HEIGHT,
overscan: 2,
});

const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
estimateSize: (index) => visibleColumns[index].getSize(), //estimate width of each column for accurate scrollbar dragging
estimateSize: (index) => visibleColumns[index].getSize(),
getScrollElement: () => parentRef.current,
horizontal: true,
overscan: 2, //how many columns to render on each side off-screen each way (adjust this for performance)
overscan: 2,
});

const virtualRows = rowVirtualizer.getVirtualItems();
const virtualColumns = columnVirtualizer.getVirtualItems();

//different virtualization strategy for columns - instead of absolute and translateY, we add empty columns to the left and right
let virtualPaddingVars = {
'--virtual-padding-left': 0,
'--virtual-padding-right': 0,
Expand All @@ -145,8 +152,8 @@ const TableNodeComponent = ({ id, data }: {

virtualPaddingLeft = virtualColumns[0]?.start ?? 0;
virtualPaddingRight =
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0);
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0);

virtualPaddingVars = {
'--virtual-padding-left': virtualPaddingLeft,
Expand All @@ -165,56 +172,54 @@ const TableNodeComponent = ({ id, data }: {
return '40px';
}
if (rows.length <= 9) {
// rows.length + header row + 8px padding
return (rows.length + 1) * FIXED_HEIGHT + 8 + 'px';
}
return 11 * FIXED_HEIGHT + 8 + 'px';
}, [showNoData, rows.length]);

console.log('render: TableNodeComponent');
return (
(
<div
ref={tableRef}
className="shadow-xl bg-gray-50 border rounded border-gray-300 text-xs"
>
<CustomHandle id={input.id} isConnectable={true} isInput={true} />

<div data-cy={'data-story-table'} className="text-gray-600 bg-gray-100 rounded font-mono -mt-3">
{isDataFetched ?
(<div
ref={parentRef}
style={{
height: tableHeight,
position: 'relative', // needed for sticky header
...virtualPaddingVars,
}}
data-cy={'data-story-table-scroll'}
className="max-h-64 max-w-256 min-w-6 nowheel overflow-auto scrollbar rounded-sm">
<table className="table-fixed grid">
<MemoizedTableHeader
headerGroups={getHeaderGroups()}
virtualColumns={virtualColumns}
/>
<MemoizedTableBody
virtualRows={virtualRows}
virtualColumns={virtualColumns}
rowVirtualizer={rowVirtualizer}
getRowModel={getRowModel}
/>
</table>
{
showNoData && (<div data-cy={'data-story-table-no-data'} className="text-center text-gray-500 p-2">
No data
</div>)
}
</div>)
: <LoadingComponent/>
}
</div>
<div
ref={tableRef}
className="shadow-xl bg-gray-50 border rounded border-gray-300 text-xs"
>
<CustomHandle id={input.id} isConnectable={true} isInput={true} />
<div data-cy={'data-story-table'} className="text-gray-600 bg-gray-100 rounded font-mono -mt-3">
{isDataFetched ? (
<div
ref={parentRef}
style={{
height: tableHeight,
position: 'relative',
...virtualPaddingVars,
}}
data-cy={'data-story-table-scroll'}
className="max-h-64 max-w-256 min-w-6 nowheel overflow-auto scrollbar rounded-sm"
>
<table className="table-fixed grid">
<MemoizedTableHeader
headerGroups={getHeaderGroups()}
virtualColumns={virtualColumns}
/>
<MemoizedTableBody
virtualRows={virtualRows}
virtualColumns={virtualColumns}
rowVirtualizer={rowVirtualizer}
getRowModel={getRowModel}
/>
</table>
{showNoData && (
<div data-cy={'data-story-table-no-data'} className="text-center text-gray-500 p-2">
No data
</div>
)}
</div>
) : (
<LoadingComponent/>
)}
</div>
)
)
;
</div>
);
};

export default memo(TableNodeComponent)
export default memo(TableNodeComponent);

0 comments on commit f424a5e

Please sign in to comment.