@workday/canvas-kit-react
Version:
The parent module that contains all Workday Canvas Kit React components
242 lines (241 loc) • 10.2 kB
JavaScript
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;
;