WAT-3: Switch to virtualization and fix search

1. Virtualize rows and smooth out the background loading
2. Add some prop type checking
This commit is contained in:
2024-07-15 01:11:29 -04:00
parent cd78cd85a5
commit 11a44d037e
4 changed files with 87 additions and 24 deletions

2
package-lock.json generated
View File

@@ -18,7 +18,6 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"globals": "^15.6.0", "globals": "^15.6.0",
"material-react-table": "^2.13.0", "material-react-table": "^2.13.0",
"prop-types": "^15.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"rss-parser": "^3.13.0" "rss-parser": "^3.13.0"
@@ -36,6 +35,7 @@
"nodemon": "^3.1.4", "nodemon": "^3.1.4",
"parcel": "^2.12.0", "parcel": "^2.12.0",
"process": "^0.11.10", "process": "^0.11.10",
"prop-types": "^15.8.1",
"punycode": "^1.4.1", "punycode": "^1.4.1",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"stream-http": "^3.2.0", "stream-http": "^3.2.0",

View File

@@ -34,7 +34,6 @@
"axios": "^1.7.2", "axios": "^1.7.2",
"globals": "^15.6.0", "globals": "^15.6.0",
"material-react-table": "^2.13.0", "material-react-table": "^2.13.0",
"prop-types": "^15.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"rss-parser": "^3.13.0" "rss-parser": "^3.13.0"
@@ -52,6 +51,7 @@
"nodemon": "^3.1.4", "nodemon": "^3.1.4",
"parcel": "^2.12.0", "parcel": "^2.12.0",
"process": "^0.11.10", "process": "^0.11.10",
"prop-types": "^15.8.1",
"punycode": "^1.4.1", "punycode": "^1.4.1",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"stream-http": "^3.2.0", "stream-http": "^3.2.0",

View File

@@ -1,6 +1,6 @@
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPlexWatchlistFeed } from '../../api/plexApi'; import { fetchPlexWatchlistFeed } from '../../api/plexApi';
import { useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import React from 'react'; import React from 'react';
import WatchlistTable from '../WatchlistTable'; import WatchlistTable from '../WatchlistTable';
@@ -8,6 +8,7 @@ export default function Watchlist() {
const { const {
data, data,
isPending, isPending,
isLoading,
isLoadingError, isLoadingError,
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
@@ -18,15 +19,34 @@ export default function Watchlist() {
queryFn: fetchPlexWatchlistFeed, queryFn: fetchPlexWatchlistFeed,
initialPageParam: process.env.BASE_RSS_FEED, initialPageParam: process.env.BASE_RSS_FEED,
getNextPageParam: (lastPage) => lastPage?.paginationLinks?.next, getNextPageParam: (lastPage) => lastPage?.paginationLinks?.next,
refetchOnWindowFocus: false,
}); });
useEffect(() => { let loadMoreItems = useCallback(
if (!isFetching && !isFetchingNextPage && hasNextPage) { (containerRefElement) => {
fetchNextPage(); // if (containerRefElement) {
} // const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
}, [isFetching, isFetchingNextPage, hasNextPage, fetchNextPage]); //once the user has scrolled within 400px of the bottom of the table, fetch more data if we can
const justItems = useMemo( if (
// scrollHeight - scrollTop - clientHeight < 400 &&
!isFetching &&
hasNextPage
) {
fetchNextPage();
}
// }
},
[fetchNextPage, isFetching, hasNextPage]
);
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) {
loadMoreItems();
}
}, [loadMoreItems, hasNextPage, isFetchingNextPage, data]);
const flatItems = useMemo(
() => data?.pages?.flatMap((row) => row.items) ?? [], () => data?.pages?.flatMap((row) => row.items) ?? [],
[data] [data]
); );
@@ -35,10 +55,11 @@ export default function Watchlist() {
<> <>
{!isPending && ( {!isPending && (
<WatchlistTable <WatchlistTable
items={justItems} items={flatItems}
isLoadingItems={isFetching} isLoadingItems={isLoading}
isErrorLoading={isLoadingError} isErrorLoading={isLoadingError}
isPageLoading={isFetchingNextPage} isPageLoading={isFetchingNextPage}
loadMoreItems={loadMoreItems}
/> />
)} )}
</> </>

View File

@@ -1,10 +1,11 @@
import { useMemo, useState } from 'react'; import { useMemo, useRef, useEffect, useState, useCallback } from 'react';
import { import {
MaterialReactTable, MaterialReactTable,
useMaterialReactTable, useMaterialReactTable,
} from 'material-react-table'; } from 'material-react-table';
import { Chip, Link, Stack } from '@mui/material'; import { Chip, Link, Stack } from '@mui/material';
import LaunchOutlined from '@mui/icons-material/Launch'; import LaunchOutlined from '@mui/icons-material/Launch';
import PropTypes from 'prop-types';
export default function WatchlistTable({ export default function WatchlistTable({
items, items,
@@ -12,16 +13,42 @@ export default function WatchlistTable({
isErrorLoading, isErrorLoading,
isPageLoading, isPageLoading,
}) { }) {
const rowVirtualizerInstanceRef = useRef(null);
const [columnFilters, setColumnFilters] = useState([]);
const [globalFilter, setGlobalFilter] = useState();
const [sorting, setSorting] = useState([]);
const mediaKeywords = 'media:keywords';
//scroll to top of table when sorting or filters change
useEffect(() => {
try {
if (rowVirtualizerInstanceRef.current?.getTotalSize() > 0) {
rowVirtualizerInstanceRef.current?.scrollToIndex(0);
}
} catch (error) {
console.error(error);
}
}, [sorting, columnFilters, globalFilter]);
const keywordOptions = useMemo( const keywordOptions = useMemo(
() => () =>
[ [...new Set(items.flatMap((item) => item[mediaKeywords].split(', ')))]
...new Set(items.flatMap((item) => item['media:keywords'].split(', '))), .sort()
].map((keyword) => { .map((keyword) => {
return { label: keyword, value: keyword }; return { label: keyword, value: keyword };
}), }),
[items] [items]
); );
const getKeywordsForRow = useCallback((renderedCellValue, row) => {
if (typeof renderedCellValue === 'string') {
return renderedCellValue.split(', ');
}
return row.original[mediaKeywords].split(', ');
}, []);
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@@ -53,11 +80,11 @@ export default function WatchlistTable({
header: 'Keywords', header: 'Keywords',
id: 'keywords', id: 'keywords',
accessorFn: (row) => { accessorFn: (row) => {
return row['media:keywords'].split(', '); return row[mediaKeywords].split(',');
}, },
Cell: ({ renderedCellValue, row }) => ( Cell: ({ renderedCellValue, row }) => (
<Stack direction="row" spacing={0.25} useFlexGap flexWrap="wrap"> <Stack direction="row" spacing={0.25} useFlexGap flexWrap="wrap">
{renderedCellValue.map((keyword) => ( {getKeywordsForRow(renderedCellValue, row).map((keyword) => (
<Chip label={keyword} key={keyword} /> <Chip label={keyword} key={keyword} />
))} ))}
</Stack> </Stack>
@@ -66,6 +93,14 @@ export default function WatchlistTable({
size: 400, size: 400,
maxSize: 700, maxSize: 700,
filterSelectOptions: keywordOptions, filterSelectOptions: keywordOptions,
filterVariant: 'multi-select',
filterFn: (row, columnId, filterValue) => {
return (
row.original[mediaKeywords]
.split(',')
.filter((keyword) => keyword.includes(filterValue)).length > 0
);
},
}, },
{ {
header: 'Rating', header: 'Rating',
@@ -74,24 +109,30 @@ export default function WatchlistTable({
filterVariant: 'multi-select', filterVariant: 'multi-select',
}, },
], ],
[keywordOptions] [keywordOptions, getKeywordsForRow]
); );
const table = useMaterialReactTable({ const table = useMaterialReactTable({
columns, columns,
data: items, data: items,
layoutMode: 'semantic', layoutMode: 'semantic',
filterFromLeafRows: true,
enableFacetedValues: true, enableFacetedValues: true,
enablePagination: false, enablePagination: false,
enableRowVirtualization: true, enableRowVirtualization: true,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 4 },
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onSortingChange: setSorting,
state: { state: {
isLoading: isLoadingItems, isLoading: isLoadingItems,
showAlertBanner: isErrorLoading,
showProgressBars: isPageLoading, showProgressBars: isPageLoading,
showAlertBanner: isErrorLoading,
columnFilters,
globalFilter,
sorting,
}, },
initialState: { initialState: {
showColumnFilters: true,
showGlobalFilter: true, showGlobalFilter: true,
}, },
}); });
@@ -100,8 +141,9 @@ export default function WatchlistTable({
} }
WatchlistTable.propTypes = { WatchlistTable.propTypes = {
items: PropTypes.object.isRequired, items: PropTypes.array.isRequired,
isLoadingItems: PropTypes.bool.isRequired, isLoadingItems: PropTypes.bool.isRequired,
isErrorLoading: PropTypes.bool.isRequired, isErrorLoading: PropTypes.bool.isRequired,
isPageLoading: PropTypes.bool.isRequired, isPageLoading: PropTypes.bool.isRequired,
loadMoreItems: PropTypes.func.isRequired,
}; };