Skip to content
6 changes: 4 additions & 2 deletions packages/app-elements/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -193,17 +196,29 @@ function ResourceListComponent<TResource extends ListableResourceType>({
metricsQuery,
type,
query,
paginationType = "infinite",
...listProps
}: UseResourceListConfig<TResource> &
ResourceListProps<TResource>): JSX.Element {
const hookConfig: UseResourceListConfig<TResource> = {
}: UseResourceListConfig<TResource> & {
paginationType?: "infinite" | "pagination"
} & ResourceListProps<TResource>): JSX.Element {
const result = useResourceList<TResource>({
type,
query,
metricsQuery,
}
const { ResourceList } = useResourceList(hookConfig)
paginationType,
})

return <ResourceList {...listProps} />
const paginationResult =
paginationType === "pagination"
? (result as UseResourceListReturnWithPagination<TResource>)
: null

return (
<>
<result.ResourceList {...listProps} />
{paginationResult != null && <paginationResult.Pagination />}
</>
)
}

const makeFilteredList: (options: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Spacer top="6">
<div className="flex items-center justify-between mt-auto">
<Text
variant="info"
tag="div"
size="x-small"
aria-live="polite"
aria-atomic="true"
>
{offsets.firstOfPage.toLocaleString()}-
{offsets.lastOfPage.toLocaleString()} of{" "}
{recordCount.toLocaleString()}
</Text>
<div className="flex gap-2 items-center justify-between text-xs">
<button
type="button"
aria-label="Previous page"
aria-disabled={isLoading || currentPage === 1}
disabled={isLoading || currentPage === 1}
onClick={() => {
onPageChange(currentPage - 1)
}}
className="p-2 disabled:opacity-30 rounded-[8px] border border-gray-200"
>
<Icon name="caretRight" className="rotate-180" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Next page"
aria-disabled={isLoading || currentPage === pageCount}
disabled={isLoading || currentPage === pageCount}
onClick={() => {
onPageChange(currentPage + 1)
}}
className="p-2 disabled:opacity-30 rounded-[8px] border border-gray-200"
>
<Icon name="caretRight" aria-hidden="true" />
</button>
</div>
</div>
</Spacer>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@ export interface FetcherResponse<TResource> {
}
}

export async function infiniteFetcher<TResource extends ListableResourceType>({
export async function listFetcher<TResource extends ListableResourceType>({
currentData,
resourceType,
client,
clientType,
query,
mode = "infinite",
pageNumber,
}: {
currentData?: FetcherResponse<Resource<TResource>>
resourceType: TResource
mode?: "infinite" | "pagination"
pageNumber?: number
} & (
| {
client: CommerceLayerClient
Expand All @@ -51,7 +55,8 @@ export async function infiniteFetcher<TResource extends ListableResourceType>({
}
)): Promise<FetcherResponse<Resource<TResource>>> {
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")
Expand All @@ -75,8 +80,12 @@ export async function infiniteFetcher<TResource extends ListableResourceType>({
// 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 } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ListableResourceType } from "@commercelayer/sdk"
import type { FetcherResponse, Resource } from "./infiniteFetcher"
import type { FetcherResponse, Resource } from "./listFetcher"

interface ResourceListInternalState<TResource extends ListableResourceType> {
isLoading: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -44,6 +44,36 @@ const ResourceListImplementation: FC<
)
}

const PaginationListImplementation: FC = () => {
const { ResourceList, Pagination } = useResourceList({
type: "orders",
paginationType: "pagination",
})

return (
<>
<ResourceList
title="All orders"
ItemTemplate={({ resource = mockedOrder }) => (
<div
data-testid={
resource.id === "mock" ? "orderItem-loading" : "orderItem-ready"
}
data-page={
resource.id.startsWith("page")
? resource.id.split("-")[0]
: undefined
}
>
Order #{resource.number}
</div>
)}
/>
<Pagination />
</>
)
}

describe("useResourceList", () => {
test("Should render list component", async () => {
const { getByTestId } = render(
Expand Down Expand Up @@ -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(
<TokenProvider kind="integration" appSlug="orders" devMode>
<CoreSdkProvider>
<PaginationListImplementation />
</CoreSdkProvider>
</TokenProvider>,
)

// 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(
<TokenProvider kind="integration" appSlug="orders" devMode>
<CoreSdkProvider>
<PaginationListImplementation />
</CoreSdkProvider>
</TokenProvider>,
)

// 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()
})
})
})
Loading
Loading