diff --git a/packages/app-elements/src/mocks/handlers.ts b/packages/app-elements/src/mocks/handlers.ts index 813be6e1f..9554d34f0 100644 --- a/packages/app-elements/src/mocks/handlers.ts +++ b/packages/app-elements/src/mocks/handlers.ts @@ -77,8 +77,10 @@ export const handlers = [ }), http.get(`https://*/api/orders`, async ({ request }) => { + const url = new URL(request.url) + const pageNumber = Number(url.searchParams.get("page[number]") ?? "1") return HttpResponse.json( - returnEmptyList(new URL(request.url)) + returnEmptyList(url) ? { data: [], meta: { record_count: 0, page_count: 1 }, @@ -87,7 +89,7 @@ export const handlers = [ data: Array(10) .fill(null) .map(() => ({ - id: Math.random().toString().substring(2, 12), + id: `page${pageNumber}-${Math.random().toString().substring(2, 12)}`, type: "orders", attributes: { number: Math.floor(Math.random() * 100_000), diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx index d390784ca..e8caf3922 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx @@ -7,7 +7,10 @@ import { type UseResourceListConfig, useResourceList, } from "#ui/resources/useResourceList" -import type { ResourceListProps } from "#ui/resources/useResourceList/useResourceList" +import type { + ResourceListProps, + UseResourceListReturnWithPagination, +} from "#ui/resources/useResourceList/useResourceList" import { makeFilterAdapters } from "./adapters" import { FiltersForm as FiltersFormComponent, @@ -193,17 +196,29 @@ function ResourceListComponent({ metricsQuery, type, query, + paginationType = "infinite", ...listProps -}: UseResourceListConfig & - ResourceListProps): JSX.Element { - const hookConfig: UseResourceListConfig = { +}: UseResourceListConfig & { + paginationType?: "infinite" | "pagination" +} & ResourceListProps): JSX.Element { + const result = useResourceList({ type, query, metricsQuery, - } - const { ResourceList } = useResourceList(hookConfig) + paginationType, + }) - return + const paginationResult = + paginationType === "pagination" + ? (result as UseResourceListReturnWithPagination) + : null + + return ( + <> + + {paginationResult != null && } + + ) } const makeFilteredList: (options: { diff --git a/packages/app-elements/src/ui/resources/useResourceList/PaginationInfo.tsx b/packages/app-elements/src/ui/resources/useResourceList/PaginationInfo.tsx new file mode 100644 index 000000000..47ed0d808 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceList/PaginationInfo.tsx @@ -0,0 +1,73 @@ +import type { JSX } from "react" +import { Icon } from "#ui/atoms/Icon" +import { Spacer } from "#ui/atoms/Spacer" +import { Text } from "#ui/atoms/Text" +import { makeCurrentPageOffsets } from "#utils/pagination" + +export interface PaginationInfoProps { + currentPage: number + pageCount: number + recordsPerPage: number + recordCount: number + isLoading: boolean + onPageChange: (page: number) => void +} + +export function PaginationInfo({ + currentPage, + pageCount, + recordsPerPage, + recordCount, + isLoading, + onPageChange, +}: PaginationInfoProps): JSX.Element { + const offsets = makeCurrentPageOffsets({ + currentPage, + recordsPerPage, + recordCount, + }) + + return ( + +
+ + {offsets.firstOfPage.toLocaleString()}- + {offsets.lastOfPage.toLocaleString()} of{" "} + {recordCount.toLocaleString()} + +
+ + +
+
+
+ ) +} diff --git a/packages/app-elements/src/ui/resources/useResourceList/infiniteFetcher.ts b/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts similarity index 79% rename from packages/app-elements/src/ui/resources/useResourceList/infiniteFetcher.ts rename to packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts index 9db99ed25..9566a6dc8 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/infiniteFetcher.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts @@ -29,15 +29,19 @@ export interface FetcherResponse { } } -export async function infiniteFetcher({ +export async function listFetcher({ currentData, resourceType, client, clientType, query, + mode = "infinite", + pageNumber, }: { currentData?: FetcherResponse> resourceType: TResource + mode?: "infinite" | "pagination" + pageNumber?: number } & ( | { client: CommerceLayerClient @@ -51,7 +55,8 @@ export async function infiniteFetcher({ } )): Promise>> { const currentPage = currentData?.meta.currentPage ?? 0 - const pageToFetch = currentPage + 1 + const pageToFetch = + mode === "pagination" && pageNumber != null ? pageNumber : currentPage + 1 if (clientType === "metricsClient" && !isValidMetricsResource(resourceType)) { throw new Error("Metrics client is not available for this resource type") @@ -75,8 +80,12 @@ export async function infiniteFetcher({ // we need the primitive array // without the sdk added methods ('meta' | 'first' | 'last' | 'get') const existingList = currentData?.list ?? [] - const uniqueList = uniqBy(existingList.concat(listResponse), "id") + // In pagination mode, replace the list instead of accumulating + const uniqueList = + mode === "pagination" + ? [...listResponse] + : uniqBy(existingList.concat(listResponse), "id") const meta = listResponse.meta - return { list: uniqueList, meta } + return { list: uniqueList, meta: { ...meta, currentPage: pageToFetch } } } diff --git a/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts b/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts index 9cf3e33c9..6f113a3ae 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts @@ -12,7 +12,7 @@ import { adaptMetricsOrderToCore, type MetricsResourceOrder, } from "./adaptMetricsOrderToCore" -import type { Resource } from "./infiniteFetcher" +import type { Resource } from "./listFetcher" export type MetricsResources = "orders" | "returns" diff --git a/packages/app-elements/src/ui/resources/useResourceList/reducer.ts b/packages/app-elements/src/ui/resources/useResourceList/reducer.ts index 935837699..56218b1b6 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/reducer.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/reducer.ts @@ -1,5 +1,5 @@ import type { ListableResourceType } from "@commercelayer/sdk" -import type { FetcherResponse, Resource } from "./infiniteFetcher" +import type { FetcherResponse, Resource } from "./listFetcher" interface ResourceListInternalState { isLoading: boolean diff --git a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx index b082b93c7..4c2ad476e 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.test.tsx @@ -1,5 +1,5 @@ import type { Order } from "@commercelayer/sdk" -import { render } from "@testing-library/react" +import { render, waitFor } from "@testing-library/react" import { act, type FC } from "react" import { CoreSdkProvider } from "#providers/CoreSdkProvider" import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" @@ -44,6 +44,36 @@ const ResourceListImplementation: FC< ) } +const PaginationListImplementation: FC = () => { + const { ResourceList, Pagination } = useResourceList({ + type: "orders", + paginationType: "pagination", + }) + + return ( + <> + ( +
+ Order #{resource.number} +
+ )} + /> + + + ) +} + describe("useResourceList", () => { test("Should render list component", async () => { const { getByTestId } = render( @@ -118,3 +148,67 @@ describe("useResourceList", () => { expect(await findByText("No orders found")).toBeVisible() }) }) + +describe("useResourceList - pagination mode", () => { + test("Should replace items (not accumulate) when navigating pages", async () => { + const { findAllByTestId, findByRole } = render( + + + + + , + ) + + // Wait for page 1 to load + const page1Items = await findAllByTestId("orderItem-ready") + expect(page1Items.length).toBe(10) + expect(page1Items[0]?.dataset.page).toBe("page1") + + // Navigate to page 2 (wait for button to be enabled) + const nextButton = await findByRole("button", { name: "Next page" }) + await act(async () => { + nextButton.click() + }) + + // Wait for page 2 items — list should be replaced, not grown + await waitFor(async () => { + const page2Items = await findAllByTestId("orderItem-ready") + expect(page2Items.length).toBe(10) + expect(page2Items[0]?.dataset.page).toBe("page2") + }) + }) + + test("Should disable prev button on first page and next button on last page", async () => { + const { findByRole } = render( + + + + + , + ) + + // Wait for buttons to appear (Pagination renders only after data is loaded) + await waitFor(async () => { + const prevButton = await findByRole("button", { name: "Previous page" }) + const nextButton = await findByRole("button", { name: "Next page" }) + expect(prevButton).toBeDisabled() + expect(nextButton).not.toBeDisabled() + }) + + // Navigate to last page (page_count is 2) + const nextButton = await findByRole("button", { name: "Next page" }) + await act(async () => { + nextButton.click() + }) + + // On page 2 (last): next should be disabled, prev enabled + await waitFor(async () => { + const prevButton = await findByRole("button", { name: "Previous page" }) + const nextButtonUpdated = await findByRole("button", { + name: "Next page", + }) + expect(nextButtonUpdated).toBeDisabled() + expect(prevButton).not.toBeDisabled() + }) + }) +}) diff --git a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx index 2c810eafa..1549f0ede 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx @@ -30,8 +30,9 @@ import { Table, Th, Tr } from "#ui/atoms/Table" import type { ThProps } from "#ui/atoms/Table/Th" import { Text } from "#ui/atoms/Text" import { InputFeedback } from "#ui/forms/InputFeedback" -import { infiniteFetcher, type Resource } from "./infiniteFetcher" +import { listFetcher, type Resource } from "./listFetcher" import { useMetricsSdkProvider } from "./metricsApiClient" +import { PaginationInfo } from "./PaginationInfo" import { initialState, reducer } from "./reducer" import { computeTitleWithTotalCount } from "./utils" import { VisibilityTrigger } from "./VisibilityTrigger" @@ -114,21 +115,18 @@ export interface UseResourceListConfig { } filter: Record } + /** + * Pagination type: 'infinite' for infinite scrolling (default), 'pagination' for classic prev/next pagination. + * Note: 'pagination' mode is only supported for Core API (not Metrics API). + */ + paginationType?: "infinite" | "pagination" } -/** - * Renders a list of resources of a given type with infinite scrolling. - * It's possible to specify a query to filter the list and either - * a React component (`ItemTemplate`) to be used as item template for the list or a function as `children` to render a custom element. - */ -export function useResourceList({ - type, - query, - metricsQuery, -}: UseResourceListConfig): { - /** The component that renders the list with infinite scrolling functionality */ +// Base return type without Pagination +interface UseResourceListReturn { + /** The component that renders the list with infinite scrolling or pagination functionality */ ResourceList: FC> - /** The raw array of fetched resources, which grows each time a new page is fetched */ + /** The raw array of fetched resources, which grows each time a new page is fetched (infinite mode) or shows current page only (pagination mode) */ list?: Array> /** Metadata related to pagination, as returned by the SDK */ meta?: ListMeta @@ -149,34 +147,85 @@ export function useResourceList({ refresh: () => void /** Indicates whether there are more pages available for fetching */ hasMorePages?: boolean -} { +} + +// Return type with Pagination component +export interface UseResourceListReturnWithPagination< + TResource extends ListableResourceType, +> extends UseResourceListReturn { + /** Pagination controls component (only shown when paginationType is 'pagination' and there are multiple pages) */ + Pagination: FC +} + +/** + * Renders a list of resources of a given type with infinite scrolling or classic pagination. + * It's possible to specify a query to filter the list and either + * a React component (`ItemTemplate`) to be used as item template for the list or a function as `children` to render a custom element. + */ +// Overload: when paginationType is explicitly 'pagination' +export function useResourceList( + config: UseResourceListConfig & { paginationType: "pagination" }, +): UseResourceListReturnWithPagination + +// Overload: when paginationType is explicitly 'infinite' or omitted +export function useResourceList( + config: UseResourceListConfig & { paginationType?: "infinite" }, +): UseResourceListReturn + +// Fallback overload: when paginationType is a union type or otherwise not narrowable to a literal +export function useResourceList( + config: UseResourceListConfig, +): UseResourceListReturn + +// Implementation signature +export function useResourceList({ + type, + query, + metricsQuery, + paginationType = "infinite", +}: UseResourceListConfig): + | UseResourceListReturn + | UseResourceListReturnWithPagination { const { sdkClient } = useCoreSdkProvider() const { metricsClient } = useMetricsSdkProvider() const [{ data, isLoading, error }, dispatch] = useReducer( reducer, initialState, ) + const [currentPage, setCurrentPage] = React.useState(1) + + // Validate that pagination mode is not used with metrics API + if (paginationType === "pagination" && metricsQuery != null) { + throw new Error( + "Pagination mode is not supported with Metrics API. Please use infinite scrolling (default) or switch to Core API.", + ) + } const isQueryChanged = useIsChanged({ value: query, onChange: () => { + setCurrentPage(1) dispatch({ type: "reset" }) - void fetchMore({ query }) + void fetchMore({ query, pageNumber: 1 }) }, }) const fetchMore = useCallback( async ({ query, + pageNumber, }: { query?: Omit, "pageNumber"> + pageNumber?: number }): Promise => { dispatch({ type: "prepare" }) try { - const listResponse = await infiniteFetcher({ + const listResponse = await listFetcher({ // when is new query, we don't want to pass existing data currentData: isQueryChanged ? undefined : data, resourceType: type, + mode: paginationType, + pageNumber, ...(metricsQuery != null ? { clientType: "metricsClient", @@ -194,12 +243,15 @@ export function useResourceList({ dispatch({ type: "error", payload: parseApiErrorMessage(err) }) } }, - [sdkClient, data, isQueryChanged], + [sdkClient, data, isQueryChanged, paginationType, metricsQuery, type], ) useEffect( function initialFetch() { - void fetchMore({ query }) + void fetchMore({ + query, + pageNumber: paginationType === "pagination" ? 1 : undefined, + }) }, [sdkClient], ) @@ -221,9 +273,23 @@ export function useResourceList({ }, []) const refresh = useCallback(() => { + setCurrentPage(1) dispatch({ type: "reset" }) - void fetchMore({ query }) - }, []) + void fetchMore({ + query, + pageNumber: paginationType === "pagination" ? 1 : undefined, + }) + }, [query, paginationType, fetchMore]) + + const handlePageChange = useCallback( + (newPage: number) => { + setCurrentPage(newPage) + void fetchMore({ query, pageNumber: newPage }).then(() => { + window.scrollTo({ top: 0 }) + }) + }, + [query, fetchMore], + ) const ResourceList = useCallback>>( ({ @@ -311,26 +377,34 @@ export function useResourceList({ { - void fetchMore({ query }) - }} - /> - ) : isLoading ? ( - Array(isFirstLoading ? 8 : 2) // we want more elements as skeleton on first mount - .fill(null) - .map((_, idx) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: Using index as key is acceptable here since items are static - - )) - ) : ( - { - if (entry.isIntersecting) { - void fetchMore({ query }) - } + void fetchMore({ + query, + pageNumber: + paginationType === "pagination" + ? currentPage + : undefined, + }) }} /> - ) + ) : paginationType === "infinite" ? ( + isLoading ? ( + Array(isFirstLoading ? 8 : 2) // we want more elements as skeleton on first mount + .fill(null) + .map((_, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Using index as key is acceptable here since items are static + + )) + ) : ( + { + if (entry.isIntersecting) { + void fetchMore({ query }) + } + }} + /> + ) + ) : null } > {data?.list.map((resource) => { @@ -358,10 +432,35 @@ export function useResourceList({ isLoading, isFirstLoading, error, + paginationType, + currentPage, + query, + fetchMore, ], ) - return { + const Pagination = useCallback(() => { + if ( + paginationType !== "pagination" || + data == null || + data.meta.pageCount <= 1 + ) { + return null + } + + return ( + + ) + }, [paginationType, data, currentPage, isLoading, handlePageChange]) + + const baseReturn: UseResourceListReturn = { ResourceList, list: data?.list, meta: data?.meta, @@ -371,12 +470,25 @@ export function useResourceList({ removeItem, refresh, fetchMore: async () => { + if (paginationType === "pagination") { + console.warn("fetchMore is not supported in pagination mode.") + return + } if (hasMorePages) { await fetchMore({ query }) } }, hasMorePages, } + + if (paginationType === "pagination") { + return { + ...baseReturn, + Pagination, + } as UseResourceListReturnWithPagination + } + + return baseReturn } function ErrorLine({ diff --git a/packages/docs/src/stories/resources/useResourceList.stories.tsx b/packages/docs/src/stories/resources/useResourceList.stories.tsx index ce33a1f46..b73958fbf 100644 --- a/packages/docs/src/stories/resources/useResourceList.stories.tsx +++ b/packages/docs/src/stories/resources/useResourceList.stories.tsx @@ -214,3 +214,93 @@ WithInfiniteScrolling.parameters = { }, }, } + +/** + * You can use classic prev/next pagination instead of infinite scrolling by setting `paginationType: "pagination"`. + * This mode is only supported for Core API (not Metrics API). + */ +export const WithPagination: StoryFn = () => { + const { ResourceList, Pagination } = useResourceList({ + type: "orders", + query: { + pageSize: 10, + }, + paginationType: "pagination", + }) + + return ( + <> + Empty} + actionButton={} + ItemTemplate={({ resource = mockedOrder, isLoading }) => { + return ( + + + + ) + }} + /> + + + ) +} +WithPagination.parameters = { + docs: { + canvas: { + sourceState: "none", + }, + }, +} + +/** + * Pagination mode also works with table variant. + */ +export const WithPaginationAsTable: StoryFn = () => { + const { ResourceList, Pagination } = useResourceList({ + type: "orders", + query: { + pageSize: 10, + }, + paginationType: "pagination", + }) + + return ( + <> + + Order + + } + ItemTemplate={({ resource = mockedOrder, isLoading }) => { + return ( + + #{resource.number} + {resource.market?.name} + + {resource.formatted_total_amount} + + + ) + }} + /> + + + ) +} +WithPaginationAsTable.parameters = { + docs: { + canvas: { + sourceState: "none", + }, + }, +}