@workday/canvas-kit-react
Version:
The parent module that contains all Workday Canvas Kit React components
379 lines (338 loc) • 12.6 kB
text/typescript
import React from 'react';
import {Model} from '@workday/canvas-kit-react/common';
import {useListModel} from './useListModel';
export interface LoadReturn<T> {
items: T[];
total?: number;
}
export interface LoadParams {
pageNumber: number;
pageSize: number;
filter: CollectionLoader['filter'];
sorter: CollectionLoader['sorter'];
}
export interface AsyncCollectionConfig<T, M extends Model<any, any>> {
/**
* The desired results per page.
*/
pageSize: number;
/**
* The total items in the collection. If you do not have this information before making an API
* call, leave it blank and it will default to `0` and be updated when `load` comes back with
* results.
* @default 0
*/
total?: number;
/**
* This function acts like a guard similar to model events. If you provide this function and it
* returns `false`, the `load` call from the loader will be cancelled. Be aware, this function can
* cause a loader to not function properly. You will need to call `loader.load(pageNumber)` on
* your own if a valid load event is cancelled. An example of using this function is in
* {@link useComboboxLoader} where the load calls are only allowed when the menu is open.
*/
shouldLoad?(
/**
* Parameters that are being sent to the `load` function
*/
params: LoadParams,
/**
* State coming from the model
*/
prevState: M['state']
): boolean;
/**
* A `load` callback function provides an easier way to hook up loader requests for data to an
* API. It will be called when the user is ready for more of the virtual data set. The `load`
* function will be called when:
* - The list UI initially loads. It will request as many pages as necessary to render enough
* items in the viewport
* - The user is scrolling the list and has run out of cached data
* - The user is using the keyboard to navigate the list and has run out of cached data
* - A filter or sorter value has changed and the cached data is now invalid
* - `loader.load` was called by the developer, manually requesting a page number
*/
load(params: LoadParams): LoadReturn<T> | Promise<LoadReturn<T>>;
}
export interface CollectionLoader {
filter: string | Record<string, string>;
updateFilter(value: string): void;
sorter: string | Record<string, string>;
updateSorter(value: string): void;
isLoading: boolean;
load(pageNumber?: number): void;
}
/**
* 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<T>(newItems: T[], startIndex = 0) {
return function itemStateUpdater(items: T[]) {
// 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
};
}
type Updater<T> = {total?: number; updater: (items: T[]) => T[]};
function load<T>(
params: {
pageNumber: number;
pageSize: number;
filter: CollectionLoader['filter'];
sorter: CollectionLoader['sorter'];
},
loadCb: AsyncCollectionConfig<T, any>['load'],
loadingCache: Record<number, boolean>
): Promise<Updater<T>> {
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)};
});
}
export function getPageNumber(index: number, pageSize: number) {
if (index <= 0) {
return 1;
}
return Math.ceil((index + 1) / pageSize);
}
function clamp(input: number, min: number, max: number) {
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
*/
export function getPagesToLoad<T>(
start: number,
end: number,
pageSize: number,
items: T[],
overScan = 3
): number[] {
const pagesToLoad: number[] = [];
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;
}
const resetItems = (total: number) => 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.
*/
export function useListLoader<
T,
// I cannot get Typescript to accept models that extend from `useListModel` to be considered valid
M extends ((...args: any[]) => any) & Omit<typeof useListModel, 'Context'>
>(
config: AsyncCollectionConfig<T, ReturnType<M>> & M['TConfig'],
modelHook: M
): {model: ReturnType<M>; loader: CollectionLoader} {
const [total, setTotal] = React.useState(config.total || 0);
const [items, setItems] = React.useState<T[]>(() => {
return resetItems(config.total || 0);
});
// keep track of pages that are currently loading
const loadingRef = React.useRef<Record<number, boolean>>({});
const [filter, setFilter] = React.useState<CollectionLoader['filter']>('');
const [sorter, setSorter] = React.useState<CollectionLoader['sorter']>('');
const updateItems = React.useCallback(
({updater, total: newTotal}: Updater<T>) => {
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.useRef(config.load);
loadRef.current = config.load;
const itemsRef = React.useRef(items);
itemsRef.current = items;
const shouldLoadRef = React.useRef(config.shouldLoad);
shouldLoadRef.current = config.shouldLoad;
const requestAnimationFrameRef = React.useRef(0);
const shouldLoadIndex = (
navigationMethod: Exclude<keyof ReturnType<typeof useListModel>['navigation'], 'getItem'>,
eventKey: keyof ReturnType<typeof useListModel>['events']
) => {
return (data: any, prevState: any) => {
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 as any);
});
});
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'),
})
) as ReturnType<typeof useListModel>;
const {virtualItems} = model.state.UNSTABLE_virtual;
const {state} = model;
const stateRef = React.useRef(state);
stateRef.current = state;
const updateFilter = (filter: CollectionLoader['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: CollectionLoader['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.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: CollectionLoader['load'] = (pageNumber = 1) => {
return load<T>(
{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 as any,
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,
},
};
}