diff --git a/packages/website/package.json b/packages/website/package.json index c57498005..fbe41de40 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -63,6 +63,7 @@ "react-slick": "^0.27.10", "react-swipeable-bottom-sheet": "^1.1.2", "react-swipeable-views": "^0.13.9", + "react-window": "^1.8.10", "redux": "^4.0.5", "redux-mock-store": "^1.5.4", "slick-carousel": "^1.8.1", @@ -109,6 +110,7 @@ "@types/react-router-dom": "^5.1.3", "@types/react-slick": "^0.23.4", "@types/react-swipeable-views": "^0.13.0", + "@types/react-window": "^1.8.8", "@types/redux-mock-store": "^1.0.2", "@types/validator": "^13.1.0", "canvas": "^2.11.2", diff --git a/packages/website/src/common/VirtualTable/index.tsx b/packages/website/src/common/VirtualTable/index.tsx new file mode 100644 index 000000000..7c13c1fa4 --- /dev/null +++ b/packages/website/src/common/VirtualTable/index.tsx @@ -0,0 +1,80 @@ +import { Table, TableBody, TableContainer } from '@material-ui/core'; +import React, { useState, useContext, forwardRef } from 'react'; +import { FixedSizeList, FixedSizeListProps } from 'react-window'; + +/** Context for cross component communication */ +const VirtualTableContext = React.createContext<{ + top: number; + setTop: (top: number) => void; + header: React.ReactNode; + footer: React.ReactNode; +}>({ + top: 0, + setTop: () => {}, + header: <>, + footer: <>, +}); + +type VirtualTableProps = { + header?: React.ReactNode; + footer?: React.ReactNode; + row: FixedSizeListProps['children']; +} & Omit; + +/** + * Displays a virtualized list as table with optional header and footer. + * It basically accepts all of the same params as the original FixedSizeList. + * From: https://codesandbox.io/p/sandbox/react-window-with-table-elements-d861o + * */ +export const VirtualTable = forwardRef( + ({ row, header, footer, ...rest }, ref) => { + const [top, setTop] = useState(0); + + return ( + + { + // @ts-ignore + // eslint-disable-next-line no-underscore-dangle + const style = ref.current?._getItemStyle(props.overscanStartIndex); + setTop(style?.top ?? 0); + + // Call the original callback + rest.onItemsRendered?.(props); + }} + ref={ref} + > + {row} + + + ); + }, +); + +/** + * The Inner component of the virtual list. This is the "Magic". + * Capture what would have been the top elements position and apply it to the table. + * Other than that, render an optional header and footer. + * */ +const Inner = React.forwardRef>( + function Inner({ children, ...rest }, ref) { + const { header, footer, top } = useContext(VirtualTableContext); + return ( + + + {header} + {children} + {footer} +
+
+ ); + }, +); diff --git a/packages/website/src/routes/Dashboard/Table/index.tsx b/packages/website/src/routes/Dashboard/Table/index.tsx index 42b825ab4..a6e29389a 100644 --- a/packages/website/src/routes/Dashboard/Table/index.tsx +++ b/packages/website/src/routes/Dashboard/Table/index.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { Box } from '@material-ui/core'; -import SiteTable from '../../HomeMap/SiteTable'; +import SiteTableContainer from '../../HomeMap/SiteTableContainer'; import { Collection } from '../collection'; const Table = ({ collection }: TableIncomingProps) => ( - @@ -26,7 +26,7 @@ exports[`SiteTable should render with given state from Redux store 1`] = `
- - + - - - + + + + + + SITE + + + + + + + SST + + (°C) + + + + + + + + STRESS + + (DHW) + + + + + + + + ALERT + + + + + + + + - - - SITE - - + Hawaii + - - - SST +   - (°C) + °C - - + + - - - STRESS +   - (DHW) + DHW - - + + - + + - - - - - - - Hawaii - - - - - 15.9 - -   - - °C - - - - - - - 0.0 - -   - - DHW - - - - - - - - - - - + + + +
`; diff --git a/packages/website/src/routes/HomeMap/SiteTable/index.test.tsx b/packages/website/src/routes/HomeMap/SiteTableContainer/index.test.tsx similarity index 88% rename from packages/website/src/routes/HomeMap/SiteTable/index.test.tsx rename to packages/website/src/routes/HomeMap/SiteTableContainer/index.test.tsx index 717a04af2..563162239 100644 --- a/packages/website/src/routes/HomeMap/SiteTable/index.test.tsx +++ b/packages/website/src/routes/HomeMap/SiteTableContainer/index.test.tsx @@ -5,13 +5,13 @@ import configureStore from 'redux-mock-store'; import { mockSite } from 'mocks/mockSite'; import { mockUser } from 'mocks/mockUser'; -import SiteTable from '.'; +import SiteTableContainer from '.'; jest.mock('./SelectedSiteCard', () => 'Mock-SelectedSiteCard'); const mockStore = configureStore([]); -describe('SiteTable', () => { +describe('SiteTableContainer', () => { let element: HTMLElement; beforeEach(() => { const store = mockStore({ @@ -39,7 +39,7 @@ describe('SiteTable', () => { element = render( - + , ).container; }); diff --git a/packages/website/src/routes/HomeMap/SiteTable/index.tsx b/packages/website/src/routes/HomeMap/SiteTableContainer/index.tsx similarity index 87% rename from packages/website/src/routes/HomeMap/SiteTable/index.tsx rename to packages/website/src/routes/HomeMap/SiteTableContainer/index.tsx index cc3d4467b..ddbff3eec 100644 --- a/packages/website/src/routes/HomeMap/SiteTable/index.tsx +++ b/packages/website/src/routes/HomeMap/SiteTableContainer/index.tsx @@ -8,8 +8,6 @@ import { MenuItem, Select, SelectProps, - Table, - TableContainer, Theme, Typography, withStyles, @@ -31,7 +29,7 @@ import { getSiteNameAndRegion } from 'store/Sites/helpers'; import { useWindowSize } from 'hooks/useWindowSize'; import { siteOptions } from 'store/Sites/types'; import SelectedSiteCard from './SelectedSiteCard'; -import SiteTableBody from './body'; +import SiteTable from './table'; import { getOrderKeysFriendlyString, Order, OrderKeys } from './utils'; import EnhancedTableHead from './tableHead'; import { Collection } from '../../Dashboard/collection'; @@ -70,7 +68,7 @@ const MOBILE_SELECT_MENU_ITEMS = Object.values(OrderKeys) [], ); -const SiteTable = ({ +const SiteTableContainer = ({ isDrawerOpen, showCard, showSiteFiltersDropdown, @@ -88,6 +86,7 @@ const SiteTable = ({ const [order, setOrder] = useState('desc'); const [orderBy, setOrderBy] = useState(OrderKeys.ALERT); + const [tableHeight, setTableHeight] = useState(undefined); const handleRequestSort = (event: unknown, property: OrderKeys) => { const isAsc = orderBy === property && order === 'asc'; @@ -198,33 +197,31 @@ const SiteTable = ({ ? `${classes.tableHolder} ${classes.scrollable}` : `${classes.tableHolder}` } - display="flex" - flexDirection="column" flex={1} + // @ts-ignore -- property ref missing from types + ref={(el: HTMLElement) => setTableHeight(el?.clientHeight)} > - - - - - - -
-
+ {!loading && ( + + + + } + height={tableHeight} + order={order} + orderBy={orderBy} + isExtended={isExtended} + collection={collection} + scrollTableOnSelection={scrollTableOnSelection} + scrollPageOnSelection={scrollPageOnSelection} + /> + )} {loading && ( }); interface SiteTableProps - extends SiteTableIncomingProps, + extends SiteTableContainerIncomingProps, WithStyles {} -interface SiteTableIncomingProps { +interface SiteTableContainerIncomingProps { // used on mobile to add descriptive elements if the drawer is closed. isDrawerOpen?: boolean; showCard?: boolean; @@ -309,7 +306,7 @@ interface SiteTableIncomingProps { scrollPageOnSelection?: boolean; } -SiteTable.defaultProps = { +SiteTableContainer.defaultProps = { isDrawerOpen: false, showCard: true, showSiteFiltersDropdown: true, @@ -319,4 +316,4 @@ SiteTable.defaultProps = { scrollPageOnSelection: undefined, }; -export default withStyles(styles)(SiteTable); +export default withStyles(styles)(SiteTableContainer); diff --git a/packages/website/src/routes/HomeMap/SiteTable/body.tsx b/packages/website/src/routes/HomeMap/SiteTableContainer/table.tsx similarity index 53% rename from packages/website/src/routes/HomeMap/SiteTable/body.tsx rename to packages/website/src/routes/HomeMap/SiteTableContainer/table.tsx index 5ba9cb70b..51ed995c1 100644 --- a/packages/website/src/routes/HomeMap/SiteTable/body.tsx +++ b/packages/website/src/routes/HomeMap/SiteTableContainer/table.tsx @@ -1,7 +1,6 @@ import { createStyles, Hidden, - TableBody, TableCell, TableRow, Theme, @@ -12,8 +11,9 @@ import { withStyles, } from '@material-ui/core'; import ErrorIcon from '@material-ui/icons/Error'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { TableRow as Row } from 'store/Homepage/types'; import { constructTableData } from 'store/Sites/helpers'; import { sitesToDisplayListSelector } from 'store/Sites/sitesListSlice'; @@ -26,6 +26,7 @@ import { dhwColorFinder } from 'helpers/degreeHeatingWeeks'; import { formatNumber } from 'helpers/numberUtils'; import { alertColorFinder } from 'helpers/bleachingAlertIntervals'; import { colors } from 'layout/App/theme'; +import { VirtualTable } from 'common/VirtualTable'; import { getComparator, Order, OrderKeys, stableSort } from './utils'; import { Collection } from '../../Dashboard/collection'; @@ -36,7 +37,7 @@ const RowNameCell = ({ classes, }: { site: Row; - classes: SiteTableBodyProps['classes']; + classes: SiteTableProps['classes']; }) => { return ( @@ -65,7 +66,7 @@ const RowNumberCell = ({ unit?: string; value: number | null; decimalPlaces?: number; - classes: SiteTableBodyProps['classes']; + classes: SiteTableProps['classes']; isExtended?: boolean; }) => { return ( @@ -96,7 +97,7 @@ const RowAlertCell = ({ classes, }: { site: Row; - classes: SiteTableBodyProps['classes']; + classes: SiteTableProps['classes']; }) => { return ( @@ -116,141 +117,188 @@ RowNumberCell.defaultProps = { isExtended: false, }; -const SiteTableBody = ({ +type RowRendererDataProps = { + sitesList: Row[]; + selectedRow?: number; + classes: SiteTableProps['classes']; + handleClick: (event: unknown, site: Row) => void; + isExtended?: boolean; +}; + +const RowRenderer = ({ + index, + style, + data, +}: ListChildComponentProps) => { + const { sitesList, selectedRow, classes, handleClick, isExtended } = data; + const site = sitesList[index]; + + return ( + handleClick(event, site)} + role="button" + tabIndex={-1} + key={site.tableData.id} + > + + + {isExtended && ( + + )} + {isExtended && ( + + )} + + {isExtended && ( + + )} + {isExtended && ( + + )} + + + ); +}; + +const SiteTable = ({ + header, order, orderBy, + height, isExtended, collection, scrollTableOnSelection, scrollPageOnSelection, classes, -}: SiteTableBodyProps) => { +}: SiteTableProps) => { const dispatch = useDispatch(); const storedSites = useSelector(sitesToDisplayListSelector); const sitesList = useMemo( () => collection?.sites || storedSites || [], [collection, storedSites], ); + const listRef = React.useRef(null); + const siteOnMap = useSelector(siteOnMapSelector); - const [selectedRow, setSelectedRow] = useState(); const theme = useTheme(); const isTablet = useMediaQuery(theme.breakpoints.down('sm')); - const mapElement = document.getElementById('sites-map'); - const handleClick = (event: unknown, site: Row) => { - setSelectedRow(site.tableData.id); dispatch(setSearchResult()); dispatch(setSiteOnMap(sitesList[site.tableData.id])); + const mapElement = document.getElementById('sites-map'); if (scrollPageOnSelection && mapElement) { mapElement.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }; - useEffect(() => { - const index = sitesList.findIndex((item) => item.id === siteOnMap?.id); - setSelectedRow(index); - }, [siteOnMap, sitesList]); + const selectedRow = useMemo( + () => sitesList.findIndex((item) => item.id === siteOnMap?.id), + [siteOnMap, sitesList], + ); + + const tableData = useMemo( + () => + stableSort( + constructTableData(sitesList), + getComparator(order, orderBy), + ), + [sitesList, order, orderBy], + ); + + const idToIndexMap = useMemo( + () => + tableData.reduce((acc, item, index) => { + // eslint-disable-next-line fp/no-mutation + acc[item.tableData.id] = index; + return acc; + }, {} as Record), + [tableData], + ); // scroll to the relevant site row when site is selected. useEffect(() => { - const child = document.getElementById(`homepage-table-row-${selectedRow}`); + if (selectedRow === undefined) return; + const itemIndex = idToIndexMap[selectedRow]; // only scroll if not on mobile (info at the top is more useful than the site row) - if (child && !isTablet && scrollTableOnSelection) { + if (itemIndex !== undefined && !isTablet && scrollTableOnSelection) { setTimeout( - () => child.scrollIntoView({ block: 'center', behavior: 'smooth' }), + () => listRef.current?.scrollToItem(itemIndex, 'start'), SCROLLT_TIMEOUT, ); } - }, [isTablet, scrollTableOnSelection, selectedRow]); + }, [idToIndexMap, isTablet, scrollTableOnSelection, selectedRow]); return ( - - {stableSort( - constructTableData(sitesList), - getComparator(order, orderBy), - ).map((site) => { - return ( - handleClick(event, site)} - role="button" - tabIndex={-1} - key={site.tableData.id} - > - - - {isExtended && ( - - )} - {isExtended && ( - - )} - - {isExtended && ( - - )} - {isExtended && ( - - )} - - - ); - })} - + ); }; @@ -288,28 +336,30 @@ const styles = (theme: Theme) => }, }, tableRow: { + position: 'static !important' as 'static', cursor: 'pointer', borderTop: `1px solid ${theme.palette.grey['300']}`, }, }); -type SiteTableBodyIncomingProps = { +type SiteTableIncomingProps = { + header: React.ReactNode; order: Order; orderBy: OrderKeys; + height?: number; isExtended?: boolean; collection?: Collection; scrollTableOnSelection?: boolean; scrollPageOnSelection?: boolean; }; -SiteTableBody.defaultProps = { +SiteTable.defaultProps = { isExtended: false, collection: undefined, scrollTableOnSelection: true, scrollPageOnSelection: false, }; -type SiteTableBodyProps = WithStyles & - SiteTableBodyIncomingProps; +type SiteTableProps = WithStyles & SiteTableIncomingProps; -export default withStyles(styles)(SiteTableBody); +export default withStyles(styles)(SiteTable); diff --git a/packages/website/src/routes/HomeMap/SiteTable/tableHead.tsx b/packages/website/src/routes/HomeMap/SiteTableContainer/tableHead.tsx similarity index 95% rename from packages/website/src/routes/HomeMap/SiteTable/tableHead.tsx rename to packages/website/src/routes/HomeMap/SiteTableContainer/tableHead.tsx index c60e1cf31..4e5f94aaf 100644 --- a/packages/website/src/routes/HomeMap/SiteTable/tableHead.tsx +++ b/packages/website/src/routes/HomeMap/SiteTableContainer/tableHead.tsx @@ -105,7 +105,14 @@ const EnhancedTableHead = ({ ]; return ( - + {headCells.map( (headCell) => diff --git a/packages/website/src/routes/HomeMap/SiteTable/utils.ts b/packages/website/src/routes/HomeMap/SiteTableContainer/utils.ts similarity index 100% rename from packages/website/src/routes/HomeMap/SiteTable/utils.ts rename to packages/website/src/routes/HomeMap/SiteTableContainer/utils.ts diff --git a/packages/website/src/routes/HomeMap/__snapshots__/index.test.tsx.snap b/packages/website/src/routes/HomeMap/__snapshots__/index.test.tsx.snap index 644f11b54..745646ff0 100644 --- a/packages/website/src/routes/HomeMap/__snapshots__/index.test.tsx.snap +++ b/packages/website/src/routes/HomeMap/__snapshots__/index.test.tsx.snap @@ -31,7 +31,7 @@ exports[`Homepage should render with given state from Redux store 1`] = `
- +
-
diff --git a/packages/website/src/routes/HomeMap/index.test.tsx b/packages/website/src/routes/HomeMap/index.test.tsx index 4599efab6..fc2006928 100644 --- a/packages/website/src/routes/HomeMap/index.test.tsx +++ b/packages/website/src/routes/HomeMap/index.test.tsx @@ -10,7 +10,7 @@ import Homepage from '.'; jest.mock('common/NavBar', () => 'Mock-NavBar'); jest.mock('./Map', () => 'Mock-Map'); -jest.mock('./SiteTable', () => 'Mock-SiteTable'); +jest.mock('./SiteTableContainer', () => 'Mock-SiteTableContainer'); const mockStore = configureStore([]); describe('Homepage', () => { diff --git a/packages/website/src/routes/HomeMap/index.tsx b/packages/website/src/routes/HomeMap/index.tsx index 77baf2bf4..5f82c81eb 100644 --- a/packages/website/src/routes/HomeMap/index.tsx +++ b/packages/website/src/routes/HomeMap/index.tsx @@ -17,7 +17,7 @@ import { siteOnMapSelector } from 'store/Homepage/homepageSlice'; import { surveysRequest } from 'store/Survey/surveyListSlice'; import { findSiteById, findInitialSitePosition } from 'helpers/siteUtils'; import HomepageNavBar from 'common/NavBar'; -import SiteTable from './SiteTable'; +import SiteTableContainer from './SiteTableContainer'; import HomepageMap from './Map'; enum QueryParamKeys { @@ -126,7 +126,7 @@ const Homepage = ({ classes }: HomepageProps) => { {showSiteTable && ( - + )} @@ -142,7 +142,7 @@ const Homepage = ({ classes }: HomepageProps) => { open={isDrawerOpen} >
- +
diff --git a/yarn.lock b/yarn.lock index 593238482..22c4258c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1256,6 +1256,13 @@ dependencies: regenerator-runtime "^0.12.0" +"@babel/runtime@^7.0.0": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" + integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.21.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" @@ -5531,6 +5538,13 @@ dependencies: "@types/react" "*" +"@types/react-window@^1.8.8": + version "1.8.8" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" + integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16", "@types/react@^16.8.12", "@types/react@^16.9.0": version "16.14.41" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.41.tgz#55c4e7ebb736ca4e2379e97a4e0c1803150ed8d4" @@ -14576,6 +14590,11 @@ memfs@^3.1.2, memfs@^3.4.1, memfs@^3.4.3: dependencies: fs-monkey "^1.0.3" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + meow@^8.0.0: version "8.1.2" resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" @@ -17831,6 +17850,14 @@ react-transition-group@^4.0.0, react-transition-group@^4.4.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-window@^1.8.10: + version "1.8.10" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" + integrity sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^16.13.1: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"