diff --git a/example/src/App.js b/example/src/App.js index 702a781..c1d80f0 100644 --- a/example/src/App.js +++ b/example/src/App.js @@ -12,6 +12,7 @@ import { Example6, Source as Example6Code } from "./examples/06-expanded"; import { Example7, Source as Example7Code } from "./examples/07-controlled"; import { Example8, Source as Example8Code } from "./examples/08-header"; import { Example9, Source as Example9Code } from "./examples/09-scroll"; +import { Example10, Source as Example10Code } from "./examples/10-infinitescroll"; import Navigation from "./Navigation"; import Props from "./Props"; import { Snippet } from "./shared/Snippet"; @@ -98,6 +99,9 @@ const LinkContainer = () => ( Custom Styling + + Infinite Scrolling + Methods @@ -214,6 +218,13 @@ const App = () => { + + + <Wrapper> + <Example10 /> + </Wrapper> + <Snippet code={Example10Code} /> + </Route> </Switch> </Page> </Application> diff --git a/example/src/examples/10-infinitescroll.js b/example/src/examples/10-infinitescroll.js new file mode 100644 index 0000000..7e7913c --- /dev/null +++ b/example/src/examples/10-infinitescroll.js @@ -0,0 +1,142 @@ +import React, { useState} from "react"; +import { Table } from "react-fluid-table"; +import { testData } from "../data"; +import _ from "lodash"; +import { useStateWithCallbackLazy } from '../useStateWithCallback' +import { Loader } from 'semantic-ui-react' + +const columns = [ + { + key: "id", + header: "ID", + width: 50, + sortable: true, + }, + { + key: "firstName", + header: "First", + sortable: true, + width: 120 + }, + { + key: "lastName", + header: "Last", + sortable: true, + width: 120 + }, + { + key: "email", + header: "Email", + sortable: true, + width: 250 + } +]; + +const loaderStyle = { width: "100%", padding: "10px" }; + +const Example10 = () => { + const [data, setData] = useState([]); + const [hasNextPage, setHasNextPage] = useState(true); + const [isNexPageLoading, setIsNextPageLoading] = useStateWithCallbackLazy(false); + + + const loadNextPage = (...args) => { + setIsNextPageLoading(true, () => { + setTimeout(() => { + setHasNextPage(data.length < testData.length) + setIsNextPageLoading(false) + setData(() => { + let newData = testData.slice(args[0], args[1]) + //console.log(newData, args[0], args[1], "Info") + return data.concat(newData) + }) + + }, 1000); + }) + } + + return ( + <React.Fragment> + <Table + data={data} + columns={columns} + infiniteLoading={true} + hasNextPage={hasNextPage} + isNextPageLoading={isNexPageLoading} + loadNextPage={(start, stop) => loadNextPage(start, stop)} + minimumBatchSize={50} + tableHeight={400} + rowHeight={35} + borders={false} + /> + {isNexPageLoading && <div style={loaderStyle}><Loader active inline='centered'>Loading...</Loader></div>} + </React.Fragment> + + ); +}; + +// const Example10 = () => <Table data={testData} columns={columns} infiniteLoading={true} hasNextPage={true} />; + +const Source = ` + +import React, { useState} from "react"; +import { Table } from "react-fluid-table"; +import _ from "lodash"; +import { useStateWithCallbackLazy } from '../useStateWithCallback' +import { Loader } from 'semantic-ui-react' + +const testData = _.range(3000).map(i => ({ + id: i + 1, + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + email: faker.internet.email() +})); + +const columns = [ + { key: "id", header: "ID", width: 50 }, + { key: "firstName", header: "First", width: 120 }, + { key: "lastName", header: "Last", width: 120 }, + { key: "email", header: "Email", width: 250 } +]; + +const loaderStyle = { width: "100%", padding: "10px" }; + +const Example = () => { + const [data, setData] = useState([]); + const [hasNextPage, setHasNextPage] = useState(true); + const [isNexPageLoading, setIsNextPageLoading] = useStateWithCallbackLazy(false); + + + const loadNextPage = (...args) => { + setIsNextPageLoading(true, () => { + setTimeout(() => { + setHasNextPage(data.length < testData.length) + setIsNextPageLoading(false) + setData(() => { + let newData = testData.slice(args[0], args[1]) + return data.concat(newData) + }) + + }, 1000); + }) + } + + return ( + <Table + data={data} + columns={columns} + infiniteLoading={true} + hasNextPage={hasNextPage} + isNextPageLoading={isNexPageLoading} + loadNextPage={(start, stop) => loadNextPage(start, stop)} + minimumBatchSize={50} + tableHeight={400} + rowHeight={35} + borders={false} + /> + ); + }; +; +`; + +export { Example10, Source }; diff --git a/example/src/useStateWithCallback.js b/example/src/useStateWithCallback.js new file mode 100644 index 0000000..031e147 --- /dev/null +++ b/example/src/useStateWithCallback.js @@ -0,0 +1,47 @@ +// Extracted from https://github.com/the-road-to-learn-react/use-state-with-callback +// Thanks to ROBIN WIERUCH + + +import { useState, useEffect, useLayoutEffect, useRef } from 'react'; + +const useStateWithCallback = (initialState, callback) => { + const [state, setState] = useState(initialState); + + useEffect(() => callback(state), [state, callback]); + + return [state, setState]; +}; + +const useStateWithCallbackInstant = (initialState, callback) => { + const [state, setState] = useState(initialState); + + useLayoutEffect(() => callback(state), [state, callback]); + + return [state, setState]; + }; + +const useStateWithCallbackLazy = initialValue => { + const callbackRef = useRef(null); + + const [value, setValue] = useState(initialValue); + + useEffect(() => { + if (callbackRef.current) { + callbackRef.current(value); + + callbackRef.current = null; + } + }, [value]); + + const setValueWithCallback = (newValue, callback) => { + callbackRef.current = callback; + + return setValue(newValue); + }; + + return [value, setValueWithCallback]; + }; + + export { useStateWithCallbackInstant, useStateWithCallbackLazy }; + +export default useStateWithCallback; \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 28fd885..929e5ee 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,5 @@ import { CSSProperties, ElementType, FC, ReactNode } from "react"; +import { ListOnItemsRenderedProps } from 'react-window'; declare module "*.svg" { const content: string; @@ -129,6 +130,7 @@ export interface ListProps { itemKey?: KeyFunction; subComponent?: ElementType<SubComponentProps>; onRowClick?: ClickFunction; + onListItemsRendered?: (props: ListOnItemsRenderedProps) => any; [key: string]: any; } @@ -199,6 +201,30 @@ export interface TableProps { * a function that takes the index of the row and returns an object. */ rowStyle?: CSSProperties | ((index: number) => CSSProperties); + + /** + * Enable or disable infinite loading. Default: `false`. + */ + infiniteLoading?: boolean; + /** + * Are there more items to load?. Default: `false`. + * (This information comes from the most recent API request.) + */ + hasNextPage?: boolean; + /** + * Are we currently loading a page of items?. Default: `false`. + * (This may be an in-flight flag in your Redux store or in-memory.) + */ + isNextPageLoading?: boolean; + /** + * Minimum number of rows to be loaded at a time; . . Default: `10`. + * (This property can be used to batch requests to reduce HTTP requests.) + */ + minimumBatchSize?: number; + /** + * Callback function responsible for loading the next page of items. Default: `undefined`. + */ + loadNextPage?: (startIndex: number, stopIndex: number) => Promise<any> | null; /** * When a column has `expander`, this component will be rendered under the row. */ diff --git a/package.json b/package.json index e196424..0dc3579 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "react-fluid-table", + "name": "@carlospence/react-fluid-table", "version": "0.4.2", "description": "A React table inspired by react-window", "author": "Mckervin Ceme <mckervinc@live.com>", @@ -27,7 +27,7 @@ "scripts": { "test": "cross-env CI=1 react-scripts test --env=jsdom", "test:watch": "react-scripts test --env=jsdom", - "build": "rm -rf dist ||: && rollup -c --environment BUILD:production", + "build": "rm -rf dist && rollup -c --environment BUILD:production", "start": "rollup -c -w --environment BUILD:development", "prepare": "yarn run build", "predeploy": "cd example && yarn install && yarn run build", @@ -65,6 +65,7 @@ "@svgr/rollup": "^5.3.1", "@testing-library/react-hooks": "^3.2.1", "@types/react-window": "^1.8.1", + "@types/react-window-infinite-loader": "^1.0.3", "babel-eslint": "^10.1.0", "cross-env": "^7.0.2", "eslint": "6.6.0", @@ -93,6 +94,7 @@ "typescript": "^3.8.3" }, "dependencies": { + "react-window-infinite-loader": "^1.0.5", "react-window": "^1.8.5" } } diff --git a/src/InfiniteLoaderWrapper.tsx b/src/InfiniteLoaderWrapper.tsx new file mode 100644 index 0000000..3023b88 --- /dev/null +++ b/src/InfiniteLoaderWrapper.tsx @@ -0,0 +1,68 @@ +import React, { ReactNode, Ref } from "react"; +import InfiniteLoader from 'react-window-infinite-loader'; + +import { ListOnItemsRenderedProps } from 'react-window'; + +type OnItemsRendered = (props: ListOnItemsRenderedProps) => any; +export interface Generic { + [key: string]: any; + } +interface InfiniteLoaderWrapperProps { + hasNextPage?: boolean; + isNextPageLoading?: boolean; + minimumBatchSize?: number, + data: Generic[]; + loadNextPage?: (startIndex: number, stopIndex: number) => Promise<any> | null; + children: (props: {onItemsRendered: OnItemsRendered, ref: Ref<any>}) => ReactNode; +} + + + +/** + * Implementing react-window-infinite-loader ExampleWrapper. + */ + + +const InfiniteLoaderWrapper = ({ hasNextPage, isNextPageLoading, minimumBatchSize, data, loadNextPage, children }: InfiniteLoaderWrapperProps) => { + + // If there are more items to be loaded then add an extra row to hold a loading indicator. + const itemCount = hasNextPage ? minimumBatchSize ? data.length + minimumBatchSize : data.length + 10 : data.length; + + // Only load 1 page of items at a time. + // Pass an empty callback to InfiniteLoader in case it asks us to load more than once. + const loadMoreItems = isNextPageLoading ? () => null : loadNextPage == undefined ? () => null : loadNextPage + + // Every row is loaded except for our loading indicator row. + const isItemLoaded = (index : number) => { + return !hasNextPage || index < data.length; + } + + + + return ( + <InfiniteLoader + isItemLoaded={isItemLoaded} + itemCount={itemCount} + loadMoreItems={loadMoreItems} + minimumBatchSize={minimumBatchSize} + > + {({onItemsRendered, ref}) => ( + children({ + ref: ref, + onItemsRendered + }) + + )} + </InfiniteLoader> + ); + +} + +InfiniteLoaderWrapper.defaultProps = { + hasNextPage: false, + isNextPageLoading: false, + loadNextPage: null, + minimumBatchSize: 10, +}; + +export default InfiniteLoaderWrapper; \ No newline at end of file diff --git a/src/RowWrapper.tsx b/src/RowWrapper.tsx index a328992..7190ae4 100644 --- a/src/RowWrapper.tsx +++ b/src/RowWrapper.tsx @@ -13,6 +13,9 @@ const RowWrapper = React.memo(({ data, index, ...rest }: Props) => { const { rows, ...metaData } = data; const row = rows[dataIndex]; + + + return !row ? null : <Row row={row} index={dataIndex} {...rest} {...metaData} />; }, areEqual); diff --git a/src/Table.tsx b/src/Table.tsx index a9570d6..8ea5047 100644 --- a/src/Table.tsx +++ b/src/Table.tsx @@ -15,6 +15,7 @@ import { DEFAULT_HEADER_HEIGHT, DEFAULT_ROW_HEIGHT, NO_NODE } from "./constants" import Header from "./Header"; import NumberTree from "./NumberTree"; import RowWrapper from "./RowWrapper"; +import InfiniteLoaderWrapper from "./InfiniteLoaderWrapper"; import { TableContext, TableContextProvider } from "./TableContext"; import TableWrapper from "./TableWrapper"; import { @@ -36,6 +37,7 @@ interface Data { */ const ListComponent = forwardRef( ({ data, width, height, itemKey, rowHeight, className, ...rest }: ListProps, ref) => { + // hooks const timeoutRef = useRef(0); const prevRef = useRef(width); @@ -224,6 +226,7 @@ const ListComponent = forwardRef( listRef.current.scrollToItem(index, align) })); + return ( <VariableSizeList className={`react-fluid-table ${className || ""}`.trim()} @@ -240,6 +243,7 @@ const ListComponent = forwardRef( const row = data.rows[dataIndex]; return generateKeyFromRow(row, index); }} + itemSize={index => { if (!index) { const header = findHeaderByUuid(uuid); @@ -250,7 +254,10 @@ const ListComponent = forwardRef( return calculateHeight(index - 1); }} - onItemsRendered={() => { + onItemsRendered={(info) => { + if (rest.onListItemsRendered) { + rest.onListItemsRendered(info); + } // find median height of rows if no rowHeight provided if (rowHeight || !tableRef.current) { return; @@ -302,6 +309,7 @@ const Table = forwardRef( tableWidth, tableStyle, headerStyle, + infiniteLoading, ...rest }: TableProps, ref @@ -310,6 +318,7 @@ const Table = forwardRef( const disableHeight = tableHeight !== undefined; const disableWidth = tableWidth !== undefined; const [uuid] = useState(`${id || "data-table"}-${randomString()}`); + //console.log(rest.data) return ( <TableContextProvider @@ -325,9 +334,31 @@ const Table = forwardRef( headerStyle }} > - {typeof tableHeight === "number" && typeof tableWidth === "number" ? ( + + +{typeof tableHeight === "number" && typeof tableWidth === "number" ? infiniteLoading ? + (<InfiniteLoaderWrapper {...rest} > + {({onItemsRendered, ref}) => (<ListComponent onListItemsRendered={onItemsRendered} ref={ref} height={tableHeight} width={tableWidth} {...rest} />)} + </InfiniteLoaderWrapper>) : ( <ListComponent ref={ref} height={tableHeight} width={tableWidth} {...rest} /> - ) : ( + ) : infiniteLoading ? + ( + <AutoSizer disableHeight={disableHeight} disableWidth={disableWidth}> + {({ height, width }) => ( + <InfiniteLoaderWrapper {...rest} > + {({onItemsRendered, ref}) => ( <ListComponent + onListItemsRendered={onItemsRendered} + ref={ref} + width={tableWidth || width} + height={tableHeight || height || guessTableHeight(rest.rowHeight)} + {...rest} + />)} + </InfiniteLoaderWrapper> + )} + </AutoSizer> + ) : + ( + <AutoSizer disableHeight={disableHeight} disableWidth={disableWidth}> {({ height, width }) => ( <ListComponent @@ -346,7 +377,12 @@ const Table = forwardRef( Table.defaultProps = { borders: true, - minColumnWidth: 80 + minColumnWidth: 80, + infiniteLoading: false, + hasNextPage: false, + isNextPageLoading: false, + loadNextPage: undefined, + minimumBatchSize: 10, }; export default Table; diff --git a/yarn.lock b/yarn.lock index 7e77119..78406d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1996,6 +1996,21 @@ dependencies: "@types/react" "*" +"@types/react-window-infinite-loader@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/react-window-infinite-loader/-/react-window-infinite-loader-1.0.3.tgz#fade6e7c625a348e83c4e1f0c897736537bc7f8c" + integrity sha512-P+XLcLxH23dwDJgPr571vUL79n++pHweCaCa5XocyxEt9YqdV627F6TCM//2zoUbGw/JnT94F8kSJ7/ijcUSIg== + dependencies: + "@types/react" "*" + "@types/react-window" "*" + +"@types/react-window@*": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe" + integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ== + dependencies: + "@types/react" "*" + "@types/react-window@^1.8.1": version "1.8.1" resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.1.tgz#6e1ceab2e6f2f78dbf1f774ee0e00f1bb0364bb3" @@ -9512,6 +9527,11 @@ react-test-renderer@^16.12.0: react-is "^16.8.6" scheduler "^0.18.0" +react-window-infinite-loader@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/react-window-infinite-loader/-/react-window-infinite-loader-1.0.5.tgz#6fe094d538a88978c2c9b623052bc50cb28c2abc" + integrity sha512-IcPIq8lADK3zsAcqoLqQGyduicqR6jWkiK2VUX5sKSI9X/rou6OWlOEexnGyujdNTG7hSG8OVBFEhLSDs4qrxg== + react-window@^1.8.5: version "1.8.5" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1"