Files
outline/app/components/PaginatedList.tsx
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

342 lines
9.5 KiB
TypeScript

import { isEqual } from "es-toolkit/compat";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { Pagination } from "@shared/constants";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePrevious from "~/hooks/usePrevious";
import { dateToHeading } from "~/utils/date";
/**
* Base interface for items that can be paginated
* @interface PaginatedItem
*/
export interface PaginatedItem {
/** Unique identifier for the item */
id?: string;
/** Last update timestamp of the item */
updatedAt?: string;
/** Creation timestamp of the item */
createdAt?: string;
}
/**
* Props for the PaginatedList component
* @template T Type of items in the list, must extend PaginatedItem
*/
interface Props<
T extends PaginatedItem,
> extends React.HTMLAttributes<HTMLDivElement> {
/**
* Function to fetch paginated data. Should return a promise resolving to an array of items
* @param options Pagination and other query options
*/
fetch?: (
// oxlint-disable-next-line no-explicit-any
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
// oxlint-disable-next-line no-explicit-any
options?: Record<string, any>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
/** Content to display when the list is empty */
empty?: JSX.Element | null;
/** Optional loading state content */
loading?: JSX.Element | null;
/** Array of items to display in the list */
items?: T[];
/** CSS class name to apply to the list container */
className?: string;
/**
* Function to render each individual item in the list
* @param item The item to render
* @param index The index of the item in the list
*/
renderItem: (item: T, index: number) => React.ReactNode;
/**
* Function to render error state
* @param options Object containing error details and retry function
*/
renderError?: (options: {
/** Details of the error */
error: Error;
/** Function to retry the fetch operation */
retry: () => void;
}) => JSX.Element;
/**
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement | string) => React.ReactNode;
/**
* Function to determine if an item is a duplicate of the previous item.
* If it returns true, the item will not be rendered.
*/
isDuplicate?: (item: T, previousItem: T) => boolean;
/**
* Handler for escape key press
* @param ev Keyboard event object
*/
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
/** Reference to the list container element */
listRef?: React.RefObject<HTMLDivElement>;
}
/**
* A reusable component that renders a paginated list with infinite scrolling
* and optional date-based section headings.
*
* @template T Type of the list items, must extend PaginatedItem
*/
const PaginatedList = <T extends PaginatedItem>({
fetch,
options,
heading,
empty = null,
loading = null,
items = [],
className,
renderItem,
renderError,
renderHeading,
isDuplicate,
onEscape,
listRef,
...rest
}: Props<T>): JSX.Element | null => {
const user = useCurrentUser({ rejectOnEmpty: false });
const { t } = useTranslation();
const [error, setError] = React.useState<Error | undefined>();
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const [isFetchingInitial, setIsFetchingInitial] = React.useState(
!items?.length
);
const [fetchCounter, setFetchCounter] = React.useState(0);
const [renderCount, setRenderCount] = React.useState(Pagination.defaultLimit);
const [offset, setOffset] = React.useState(0);
const [allowLoadMore, setAllowLoadMore] = React.useState(true);
const reset = React.useCallback(() => {
setOffset(0);
setAllowLoadMore(true);
setRenderCount(Pagination.defaultLimit);
setIsFetching(false);
setIsFetchingInitial(false);
setIsFetchingMore(false);
}, []);
const fetchResults = React.useCallback(async () => {
if (!fetch) {
return;
}
setIsFetching(true);
const counter = fetchCounter + 1;
setFetchCounter(counter);
const limit = options?.limit ?? Pagination.defaultLimit;
setError(undefined);
try {
const results = await fetch({
limit,
offset,
...options,
});
if (!results) {
return;
}
if (offset !== 0) {
setRenderCount((prevCount) => prevCount + limit);
}
if (results.length === 0 || results.length < limit) {
setAllowLoadMore(false);
} else {
setOffset((prevOffset) => prevOffset + limit);
}
setIsFetchingInitial(false);
} catch (err) {
setError(err);
} finally {
// only the most recent fetch should end the loading state
if (counter >= fetchCounter) {
setIsFetching(false);
setIsFetchingMore(false);
}
}
}, [fetch, fetchCounter, offset, options]);
const loadMoreResults = React.useCallback(async () => {
// Don't paginate if there aren't more results or we're currently fetching
if (!allowLoadMore || isFetching) {
return;
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = (items?.length ?? 0) - renderCount;
if (leftToRender > 0) {
setRenderCount((prevCount) => prevCount + Pagination.defaultLimit);
}
// If there are less than a pages results in the cache go ahead and fetch
// another page from the server
if (leftToRender <= Pagination.defaultLimit) {
setIsFetchingMore(true);
await fetchResults();
}
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
const prevFetch = usePrevious(fetch);
const prevOptions = usePrevious(options);
// Initial fetch on mount
React.useEffect(() => {
if (fetch) {
void fetchResults();
}
}, [fetch]);
// Handle updates to fetch or options
React.useEffect(() => {
if (!prevFetch || !prevOptions) {
return; // Skip on initial mount since it's handled by the above effect
}
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
reset();
void fetchResults();
}
}, [fetch, options, reset, fetchResults, prevFetch, prevOptions]);
// Computed property equivalent
const itemsToRender = React.useMemo(() => {
const sliced = items?.slice(0, renderCount) ?? [];
if (!isDuplicate) {
return sliced;
}
return sliced.filter((item, index) => {
if (index === 0) {
return true;
}
return !isDuplicate(item, sliced[index - 1]);
});
}, [items, renderCount, isDuplicate]);
const showLoading =
isFetching &&
!isFetchingMore &&
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
if (showLoading) {
return (
loading || (
<DelayedMount>
<div className={className}>
<PlaceholderList count={5} />
</div>
</DelayedMount>
)
);
}
if (items?.length === 0) {
if (error && renderError) {
return renderError({ error, retry: fetchResults });
}
return empty;
}
return (
<React.Fragment>
{heading}
<ArrowKeyNavigation
role={rest.role}
aria-label={rest["aria-label"]}
onEscape={onEscape}
className={className}
items={itemsToRender}
ref={listRef}
>
{() => {
let previousHeading = "";
return itemsToRender.map((item, index) => {
const children = renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
t,
user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (
children &&
(!previousHeading || currentHeading !== previousHeading)
) {
previousHeading = currentHeading;
return (
<React.Fragment key={"id" in item && item.id ? item.id : index}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
});
}}
</ArrowKeyNavigation>
{allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint
key={items?.length + renderCount}
onEnter={loadMoreResults}
/>
</div>
)}
</React.Fragment>
);
};
export default PaginatedList;