UNPKG

@workday/canvas-kit-react

Version:

The parent module that contains all Workday Canvas Kit React components

242 lines (241 loc) • 10.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.useListLoader = exports.getPagesToLoad = exports.getPageNumber = void 0; const react_1 = __importDefault(require("react")); /** * A loading buffer is how many items we want to look at ahead of the current virtual window. A * higher amount can load too many pages initially. A lower number can cause items to not be loaded * yet when scrolling. This will probably become a floating number later based on scrolling speed. * Ideally the faster the user scrolls, this number will be higher or "predict" where to focus loading * so the user doesn't see unloaded items. But if the user is scrolling too fast, we may simply delay * data loading. That logic would be based on timers. For now, we'll just hardcode. */ const loadingBuffer = 3; function updateItems(newItems, startIndex = 0) { return function itemStateUpdater(items) { // replace existing indexes with data from external service newItems.forEach((item, index) => { items[index + startIndex] = item; }); const returnedItems = items.concat([]); return returnedItems; // return a new reference }; } function load(params, loadCb, loadingCache) { if (loadingCache[params.pageNumber]) { return Promise.resolve({ updater: items => items }); } loadingCache[params.pageNumber] = true; return Promise.resolve(loadCb(params)).then(({ items, total }) => { loadingCache[params.pageNumber] = false; return { total, updater: updateItems(items, (params.pageNumber - 1) * params.pageSize) }; }); } function getPageNumber(index, pageSize) { if (index <= 0) { return 1; } return Math.ceil((index + 1) / pageSize); } exports.getPageNumber = getPageNumber; function clamp(input, min, max) { return Math.max(Math.min(input, max), min); } /** * * @param start The start index of the virtual window * @param end The end index of the virtual window * @param pageSize Expected load page size * @param items Sparse array of all loaded items * @param overScan How many items ahead we want to look ahead to trigger loading. Unloaded items * that are this many items outside the current start and end numbers will trigger a loading of * a page */ function getPagesToLoad(start, end, pageSize, items, overScan = 3) { const pagesToLoad = []; const lookBehindIndex = clamp(start - overScan, 0, items.length - 1); if (start >= 0 && !items[lookBehindIndex]) { const pageNumber = getPageNumber(lookBehindIndex, pageSize); pagesToLoad.push(pageNumber); } const lookaheadIndex = clamp(end + overScan - 1, 0, items.length - 1); if (end < items.length - 1 && !items[lookaheadIndex]) { const pageNumber = getPageNumber(lookaheadIndex, pageSize); if (!pagesToLoad.includes(pageNumber)) { pagesToLoad.push(pageNumber); } } return pagesToLoad; } exports.getPagesToLoad = getPagesToLoad; const resetItems = (total) => Array(total).fill(undefined); /** * Create a data loader and a model. The list loader should be used on virtual data sets with * possibly large amounts of data. The model should be passed to a collection component. The loader * can be used to manipulate filters, sorters, and clear cache. * * A simple loader using [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) could * look like the following: * * ```ts * const {model, loader} = useListLoader( * { * total: 1000, * pageSize: 20, * async load({pageNumber, pageSize}) { * return fetch('/myUrl') * .then(response => response.json()) * .then(response => { * return {total: response.total, items: response.items}; * }); * }, * }, * useListModel * ); * ``` * * @param config Config that contains the loader config and the model config * @param modelHook A model hook that describes which model should be returned. */ function useListLoader(config, modelHook) { const [total, setTotal] = react_1.default.useState(config.total || 0); const [items, setItems] = react_1.default.useState(() => { return resetItems(config.total || 0); }); // keep track of pages that are currently loading const loadingRef = react_1.default.useRef({}); const [filter, setFilter] = react_1.default.useState(''); const [sorter, setSorter] = react_1.default.useState(''); const updateItems = react_1.default.useCallback(({ updater, total: newTotal }) => { if (newTotal !== undefined && total !== newTotal) { setTotal(newTotal); setItems(() => { const items = resetItems(newTotal); return updater(items); }); return; } setItems(updater); }, [total]); // Our `useEffect` functions use `config.load`, but we don't want to rerun the effects if the user // sends a different load function every render const loadRef = react_1.default.useRef(config.load); loadRef.current = config.load; const itemsRef = react_1.default.useRef(items); itemsRef.current = items; const shouldLoadRef = react_1.default.useRef(config.shouldLoad); shouldLoadRef.current = config.shouldLoad; const requestAnimationFrameRef = react_1.default.useRef(0); const shouldLoadIndex = (navigationMethod, eventKey) => { return (data, prevState) => { const index = model.navigation[navigationMethod](model.state.cursorIndexRef.current, model); const params = { pageNumber: Math.floor(index / config.pageSize) + 1, pageSize: config.pageSize, filter, sorter, }; if (!items[index]) { if (config.shouldLoad && !config.shouldLoad(params, prevState)) { return false; } load(params, loadRef.current, loadingRef.current) .then(updateItems) .then(() => { cancelAnimationFrame(requestAnimationFrameRef.current); requestAnimationFrameRef.current = requestAnimationFrame(() => { model.events[eventKey](data); }); }); return false; } return true; }; }; const model = modelHook(modelHook.mergeConfig(config, { // Loaders should virtualize by default. If they do not, it is an infinite scroll list shouldVirtualize: true, items, shouldGoToNext: shouldLoadIndex('getNext', 'goToNext'), shouldGoToPrevious: shouldLoadIndex('getPrevious', 'goToPrevious'), shouldGoToLast: shouldLoadIndex('getLast', 'goToLast'), shouldGoToFirst: shouldLoadIndex('getFirst', 'goToFirst'), shouldGoToNextPage: shouldLoadIndex('getNextPage', 'goToNextPage'), shouldGoToPreviousPage: shouldLoadIndex('getPreviousPage', 'goToPreviousPage'), })); const { virtualItems } = model.state.UNSTABLE_virtual; const { state } = model; const stateRef = react_1.default.useRef(state); stateRef.current = state; const updateFilter = (filter) => { loadingRef.current = {}; setFilter(filter); const params = { pageNumber: 1, pageSize: config.pageSize, filter, sorter, }; if (config.shouldLoad && !config.shouldLoad(params, state)) { return; } load(params, loadRef.current, loadingRef.current).then(updateItems); }; const updateSorter = (sorter) => { loadingRef.current = {}; setSorter(sorter); const params = { pageNumber: 1, pageSize: config.pageSize, filter, sorter, }; if (config.shouldLoad && !config.shouldLoad(params, state)) { return; } load(params, loadRef.current, loadingRef.current).then(updateItems); }; // Our only signal to trigger loading is if our virtual indexes are too close to boundaries. react_1.default.useEffect(() => { if (!virtualItems.length) { return; } const firstItem = virtualItems[0]; const lastItem = virtualItems[virtualItems.length - 1]; const pagesToLoad = getPagesToLoad(firstItem.index, lastItem.index, config.pageSize, itemsRef.current, loadingBuffer); pagesToLoad.forEach(pageNumber => { const params = { pageNumber, pageSize: config.pageSize, filter, sorter }; const shouldLoad = shouldLoadRef.current; if (shouldLoad && !shouldLoad(params, stateRef.current)) { return; } load(params, loadRef.current, loadingRef.current).then(updateItems); }); return () => { cancelAnimationFrame(requestAnimationFrameRef.current); }; }, [virtualItems, config.pageSize, filter, sorter, updateItems]); const loaderLoad = (pageNumber = 1) => { return load({ pageNumber, pageSize: config.pageSize, filter, sorter }, loadRef.current, loadingRef.current).then(updateItems); }; // Typescript won't allow me to say the included model extends `useListModel` for some reason, so // we have no way to type check it. Without the constraint, Typescript doesn't know how type it // properly, so we have an `as any`. return { model: model, loader: { filter, updateFilter, sorter, updateSorter, // we reduce the ref instead of a state variable because the loader mutates this ref and // triggers state changes when loading is finished. isLoading: Object.values(loadingRef.current).reduce((loading, result) => loading || result, false), load: loaderLoad, }, }; } exports.useListLoader = useListLoader;