refactor: Convert PaginatedList component to functional style (#9030)

* refactor: Convert PaginatedList component to functional style

* tsc

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-04-24 07:03:18 -04:00
committed by GitHub
parent 298298223b
commit b7c13f092b
17 changed files with 295 additions and 243 deletions
+2 -2
View File
@@ -46,10 +46,10 @@ function DocumentViews({ document, isOpen }: Props) {
return (
<>
{isOpen && (
<PaginatedList
<PaginatedList<User>
aria-label={t("Viewers")}
items={users}
renderItem={(model: User) => {
renderItem={(model) => {
const view = documentViews.find((v) => v.userId === model.id);
const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id);
+2 -2
View File
@@ -56,7 +56,7 @@ const FilterOptions = ({
: "";
const renderItem = React.useCallback(
(option: TFilterOption) => (
(option) => (
<MenuItem
key={option.key}
onClick={() => {
@@ -174,7 +174,7 @@ const FilterOptions = ({
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList
<PaginatedList<TFilterOption>
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
@@ -79,11 +79,11 @@ function Notifications(
</Header>
<React.Suspense fallback={null}>
<Scrollable ref={ref} flex topShadow>
<PaginatedList
<PaginatedList<Notification>
fetch={notifications.fetchPage}
options={{ archived: false }}
items={isOpen ? notifications.orderedData : undefined}
renderItem={(item: Notification) => (
renderItem={(item) => (
<NotificationListItem
key={item.id}
notification={item}
+3 -3
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: any) => Promise<Document[] | undefined>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
empty?: JSX.Element;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
@@ -34,7 +34,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
const { t } = useTranslation();
return (
<PaginatedList
<PaginatedList<Document>
aria-label={t("Documents")}
items={documents}
empty={empty}
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index) => (
renderItem={(item, _index) => (
<DocumentListItem
key={item.id}
document={item}
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
empty?: JSX.Element;
};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
+23 -15
View File
@@ -1,13 +1,15 @@
import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import * as React from "react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import { Component as PaginatedList } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const i18n = getI18n();
const authStore = {};
const props = {
i18n,
@@ -17,19 +19,23 @@ describe("PaginatedList", () => {
it("with no items renders nothing", () => {
const result = render(
<PaginatedList items={[]} renderItem={render} {...props} />
<Provider auth={authStore}>
<PaginatedList items={[]} renderItem={render} {...props} />
</Provider>
);
expect(result.container.innerHTML).toEqual("");
});
it("with no items renders empty prop", async () => {
const result = render(
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
<Provider auth={authStore}>
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
await expect(
result.findAllByText("Sorry, no results")
@@ -42,13 +48,15 @@ describe("PaginatedList", () => {
id: "one",
};
render(
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
<Provider auth={authStore}>
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>{" "}
</Provider>
);
expect(fetch).toHaveBeenCalledWith({
...options,
+238 -194
View File
@@ -1,265 +1,309 @@
import isEqual from "lodash/isEqual";
import { observable, action, computed } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DelayedMount from "~/components/DelayedMount";
import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
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;
}
type Props<T> = WithTranslation &
RootStore &
React.HTMLAttributes<HTMLDivElement> & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (item: T, index: number) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
listRef?: React.RefObject<HTMLDivElement>;
};
/**
* 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?: (
options: Record<string, any> | undefined
) => Promise<unknown[] | undefined> | undefined;
@observer
class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
Props<T>
> {
@observable
error?: Error;
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
@observable
isFetchingMore = false;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@observable
isFetching = false;
/** Content to display when the list is empty */
empty?: JSX.Element | null;
@observable
isFetchingInitial = !this.props.items?.length;
/** Optional loading state content */
loading?: JSX.Element | null;
@observable
fetchCounter = 0;
/** Array of items to display in the list */
items?: T[];
@observable
renderCount = Pagination.defaultLimit;
/** CSS class name to apply to the list container */
className?: string;
@observable
offset = 0;
/**
* 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;
@observable
allowLoadMore = true;
/**
* 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;
componentDidMount() {
void this.fetchResults();
}
/**
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
componentDidUpdate(prevProps: Props<T>) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
void this.fetchResults();
}
}
/**
* Handler for escape key press
* @param ev Keyboard event object
*/
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = Pagination.defaultLimit;
this.isFetching = false;
this.isFetchingInitial = false;
this.isFetchingMore = false;
};
/** Reference to the list container element */
listRef?: React.RefObject<HTMLDivElement>;
}
@action
fetchResults = async () => {
if (!this.props.fetch) {
/**
* 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,
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;
}
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = this.props.options?.limit ?? Pagination.defaultLimit;
this.error = undefined;
setIsFetching(true);
const counter = fetchCounter + 1;
setFetchCounter(counter);
const limit = options?.limit ?? Pagination.defaultLimit;
setError(undefined);
try {
const results = await this.props.fetch({
const results = await fetch({
limit,
offset: this.offset,
...this.props.options,
offset,
...options,
});
if (this.offset !== 0) {
this.renderCount += limit;
if (offset !== 0) {
setRenderCount((prevCount) => prevCount + limit);
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
setAllowLoadMore(false);
} else {
this.offset += limit;
setOffset((prevOffset) => prevOffset + limit);
}
this.isFetchingInitial = false;
setIsFetchingInitial(false);
} catch (err) {
this.error = err;
setError(err);
} finally {
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
if (counter >= fetchCounter) {
setIsFetching(false);
setIsFetchingMore(false);
}
}
};
}, [fetch, fetchCounter, offset, options]);
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were currently fetching
if (!this.allowLoadMore || this.isFetching) {
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 = (this.props.items?.length ?? 0) - this.renderCount;
const leftToRender = (items?.length ?? 0) - renderCount;
if (leftToRender > 0) {
this.renderCount += Pagination.defaultLimit;
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) {
this.isFetchingMore = true;
await this.fetchResults();
setIsFetchingMore(true);
await fetchResults();
}
};
}, [allowLoadMore, isFetching, items?.length, renderCount, fetchResults]);
@computed
get itemsToRender() {
return this.props.items?.slice(0, this.renderCount) ?? [];
}
React.useEffect(() => {
void fetchResults();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
render() {
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
const prevFetch = usePrevious(fetch);
const prevOptions = usePrevious(options);
const showLoading =
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || (this.fetchCounter <= 1 && this.isFetchingInitial));
if (showLoading) {
return (
this.props.loading || (
<DelayedMount>
<div className={this.props.className}>
<PlaceholderList count={5} />
</div>
</DelayedMount>
)
);
// Equivalent to componentDidUpdate
React.useEffect(() => {
if (prevFetch !== fetch || !isEqual(prevOptions, options)) {
reset();
void fetchResults();
}
}, [fetch, options, reset, prevFetch, prevOptions, fetchResults]);
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
// Computed property equivalent
const itemsToRender = React.useMemo(
() => items?.slice(0, renderCount) ?? [],
[items, renderCount]
);
return empty;
}
const showLoading =
isFetching &&
!isFetchingMore &&
(!items?.length || (fetchCounter <= 1 && isFetchingInitial));
if (showLoading) {
return (
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
ref={this.props.listRef}
>
{() => {
let previousHeading = "";
return this.itemsToRender.map((item, index) => {
const children = this.props.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,
this.props.t,
auth.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>
{this.allowLoadMore && (
<div style={{ height: "1px" }}>
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
loading || (
<DelayedMount>
<div className={className}>
<PlaceholderList count={5} />
</div>
)}
</>
</DelayedMount>
)
);
}
}
export const Component = PaginatedList;
if (items?.length === 0) {
if (error && renderError) {
return renderError({ error, retry: fetchResults });
}
export default withTranslation()(withStores(PaginatedList));
return empty;
}
return (
<React.Fragment>
{heading}
<ArrowKeyNavigation
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={renderCount} onEnter={loadMoreResults} />
</div>
)}
</React.Fragment>
);
};
export default PaginatedList;
+2 -2
View File
@@ -200,7 +200,7 @@ function SearchPopover({ shareId, className }: Props) {
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList
<PaginatedList<SearchResult>
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
@@ -209,7 +209,7 @@ function SearchPopover({ shareId, className }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index) => (
renderItem={(item, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
@@ -82,12 +82,12 @@ function ArchiveLink() {
</div>
{expanded === true ? (
<Relative>
<PaginatedList
<PaginatedList<Collection>
aria-label={t("Archived collections")}
items={collections.archived}
loading={<PlaceholderCollections />}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection) => (
renderItem={(item) => (
<ArchivedCollectionLink
key={item.id}
depth={1}
@@ -54,7 +54,7 @@ function Collections() {
<Flex column>
<Header id="collections" title={t("Collections")}>
<Relative>
<PaginatedList
<PaginatedList<Collection>
options={params}
aria-label={t("Collections")}
items={collections.allActive}
@@ -69,7 +69,7 @@ function Collections() {
) : undefined
}
renderError={(props) => <StyledError {...props} />}
renderItem={(item: Collection, index) => (
renderItem={(item, index) => (
<DraggableCollectionLink
key={item.id}
collection={item}
+2 -2
View File
@@ -144,10 +144,10 @@ function Insights() {
small
/>
)}
<PaginatedList
<PaginatedList<User>
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model: User) => (
renderItem={(model) => (
<ListItem
key={model.id}
title={model.name}
+2 -2
View File
@@ -58,11 +58,11 @@ function ApiKeys() {
}}
/>
</Text>
<PaginatedList
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<h2>{t("All")}</h2>}
renderItem={(apiKey: ApiKey) => (
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
+2 -2
View File
@@ -48,7 +48,7 @@ function Export() {
{t("Export data")}
</Button>
<br />
<PaginatedList
<PaginatedList<FileOperation>
items={fileOperations.exports}
fetch={fileOperations.fetchPage}
options={{
@@ -59,7 +59,7 @@ function Export() {
<Trans>Recent exports</Trans>
</h2>
}
renderItem={(item: FileOperation) => (
renderItem={(item) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
+2 -2
View File
@@ -183,7 +183,7 @@ function Import() {
))}
</div>
<br />
<PaginatedList
<PaginatedList<ImportModel | FileOperation>
items={allImports}
fetch={fetchImports}
heading={
@@ -191,7 +191,7 @@ function Import() {
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item: ImportModel | FileOperation) =>
renderItem={(item) =>
item instanceof ImportModel ? (
<ImportListItem key={item.id} importModel={item} />
) : (
+2 -2
View File
@@ -61,12 +61,12 @@ function PersonalApiKeys() {
}}
/>
</Text>
<PaginatedList
<PaginatedList<ApiKey>
fetch={apiKeys.fetchPage}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
renderItem={(apiKey) => (
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
)}
/>
@@ -268,14 +268,14 @@ export const ViewGroupMembersDialog = observer(function ({
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
<PaginatedList<User>
items={users.inGroup(group.id)}
fetch={groupUsers.fetchPage}
options={{
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(user: User) => (
renderItem={(user) => (
<GroupMemberListItem
key={user.id}
user={user}
@@ -382,7 +382,7 @@ const AddPeopleToGroupDialog = observer(function ({
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList
<PaginatedList<User>
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
@@ -392,7 +392,7 @@ const AddPeopleToGroupDialog = observer(function ({
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item: User) => (
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
+4 -4
View File
@@ -52,18 +52,18 @@ function Webhooks() {
in near real-time.
</Trans>
</Text>
<PaginatedList
<PaginatedList<WebhookSubscription>
fetch={webhookSubscriptions.fetchPage}
items={webhookSubscriptions.enabled}
heading={<h2>{t("Active")}</h2>}
renderItem={(webhook: WebhookSubscription) => (
renderItem={(webhook) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>
<PaginatedList
<PaginatedList<WebhookSubscription>
items={webhookSubscriptions.disabled}
heading={<h2>{t("Inactive")}</h2>}
renderItem={(webhook: WebhookSubscription) => (
renderItem={(webhook) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>