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:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user