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
28 changes: 0 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
"dependencies": {
"@code4recovery/spec": "1.1.9",
"@emotion/react": "^11.14.0",
"@tanstack/react-virtual": "^3.13.23",
"deepmerge": "^4.3.1",
"leaflet": "^1.9.4",
"luxon": "^3.7.2",
Expand Down
21 changes: 12 additions & 9 deletions public/app.js

Large diffs are not rendered by default.

66 changes: 28 additions & 38 deletions src/components/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

import { useWindowVirtualizer } from '@tanstack/react-virtual';
import { useNavigate } from 'react-router-dom';

import { formatUrl, formatString as i18n } from '../helpers';
Expand Down Expand Up @@ -41,28 +40,31 @@ export default function Table() {
'time',
];
const [showInProgress, setShowInProgress] = useState(false);
const [scrollMargin, setScrollMargin] = useState(0);
const tbodyRef = useCallback((node: HTMLTableSectionElement | null) => {
if (node) setScrollMargin(node.offsetTop);
}, []);
const [page, setPage] = useState(1);
const [visibleSlugs, setVisibleSlugs] = useState<string[]>([]);
const loaderRef = useRef<HTMLDivElement>(null);

const rowVirtualizer = useWindowVirtualizer({
count: filteredSlugs?.length ?? 0,
estimateSize: () => 48,
overscan: 25,
scrollMargin,
});
// add rows to table as user scrolls
useEffect(() => {
if (!loaderRef.current || !filteredSlugs.length) return;

const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop =
virtualItems.length > 0
? virtualItems[0].start - rowVirtualizer.options.scrollMargin
: 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const observer = new IntersectionObserver(
([{ isIntersecting }]) => {
if (isIntersecting && visibleSlugs.length < filteredSlugs.length) {
setPage(prev => prev + 1);
}
},
{ rootMargin: '250px 0' }
);

observer.observe(loaderRef.current);

return () => observer.disconnect();
}, [filteredSlugs.length, visibleSlugs.length]);

useEffect(() => {
setVisibleSlugs(filteredSlugs.slice(0, page * 25));
}, [page, filteredSlugs]);

if (error) {
return null;
Expand Down Expand Up @@ -204,25 +206,13 @@ export default function Table() {
)}
</tbody>
)}
<tbody ref={tbodyRef}>
{paddingTop > 0 && (
<tr>
<td colSpan={columns.length} style={{ height: paddingTop }} />
</tr>
)}
{virtualItems.map(virtualRow => (
<Row
slug={filteredSlugs[virtualRow.index]}
key={virtualRow.index}
/>
<tbody>
{visibleSlugs.map((slug, index) => (
<Row slug={slug} key={index} />
))}
{paddingBottom > 0 && (
<tr>
<td colSpan={columns.length} style={{ height: paddingBottom }} />
</tr>
)}
</tbody>
</table>
<div ref={loaderRef} />
</div>
);
}
3 changes: 3 additions & 0 deletions src/styles/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ export const tableInProgressCss = css`
`;

export const tableWrapperCss = css`
display: flex;
flex-direction: column;
flex: 1;
margin: 0 ${size.gutter * -1}px ${size.gutter * -1}px;
width: calc(100% + ${size.gutter * 2}px);
`;
75 changes: 24 additions & 51 deletions test/__tests__/Table.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ vi.mock('../../src/hooks', async () => {
});

// Mock @tanstack/react-virtual using vi.fn() so tests can override with mockImplementationOnce.
const defaultVirtualizer = ({ count, scrollMargin }: { count: number; scrollMargin: number }) => {
const defaultVirtualizer = ({
count,
scrollMargin,
}: {
count: number;
scrollMargin: number;
}) => {
const items = Array.from({ length: count }, (_, i) => ({
index: i,
start: i * 48,
Expand All @@ -74,13 +80,6 @@ const defaultVirtualizer = ({ count, scrollMargin }: { count: number; scrollMarg
};
};

const mockUseWindowVirtualizer = vi.fn(defaultVirtualizer);

vi.mock('@tanstack/react-virtual', () => ({
useWindowVirtualizer: (...args: Parameters<typeof defaultVirtualizer>) =>
mockUseWindowVirtualizer(...args),
}));

const renderTable = () =>
render(
<MemoryRouter>
Expand All @@ -90,6 +89,23 @@ const renderTable = () =>
</MemoryRouter>
);

// @ts-ignore
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {
return null;
}
observe() {
return null;
}
takeRecords() {
return [];
}
unobserve() {
return null;
}
};

describe('<Table />', () => {
afterEach(() => {
cleanup();
Expand Down Expand Up @@ -151,46 +167,3 @@ describe('<Table />', () => {
expect(button).toBeInTheDocument();
});
});

describe('<Table /> virtualizer padding rows', () => {
afterEach(() => {
cleanup();
mockUseWindowVirtualizer.mockImplementation(defaultVirtualizer);
});

it('renders a top padding row when paddingTop > 0', () => {
// start=96, scrollMargin=0 -> paddingTop = 96 - 0 = 96
mockUseWindowVirtualizer.mockImplementationOnce(({ count }: { count: number }) => ({
getVirtualItems: () => [
{ index: 0, start: 96, end: 144, key: 0, size: 48, lane: 0 },
{ index: 1, start: 144, end: 192, key: 1, size: 48, lane: 0 },
],
getTotalSize: () => count * 48,
options: { scrollMargin: 0 },
}));

renderTable();
// A spacer <td> with a non-empty height style is rendered for paddingTop
const spacers = Array.from(document.querySelectorAll('tbody tr td')).filter(
td => (td as HTMLElement).style.height !== ''
);
expect(spacers.length).toBeGreaterThan(0);
});

it('renders a bottom padding row when paddingBottom > 0', () => {
// Only 1 item rendered but totalSize > item end -> paddingBottom > 0
mockUseWindowVirtualizer.mockImplementationOnce(({ count }: { count: number }) => ({
getVirtualItems: () => [
{ index: 0, start: 0, end: 48, key: 0, size: 48, lane: 0 },
],
getTotalSize: () => count * 48,
options: { scrollMargin: 0 },
}));

renderTable();
const spacers = Array.from(document.querySelectorAll('tbody tr td')).filter(
td => (td as HTMLElement).style.height !== ''
);
expect(spacers.length).toBeGreaterThan(0);
});
});
Loading