From e6ab41ef04084cc1ce491f8ea6932dd1dea38f69 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 13 May 2026 11:46:35 +0300 Subject: [PATCH 1/6] Add limit/marker query params to transfers and deployments list requests Signed-off-by: Mihaela Balutoiu --- src/sources/DeploymentSource.ts | 22 ++++++++++++++++++---- src/sources/TransferSource.ts | 28 ++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/sources/DeploymentSource.ts b/src/sources/DeploymentSource.ts index 05251d57..2bdbcb48 100644 --- a/src/sources/DeploymentSource.ts +++ b/src/sources/DeploymentSource.ts @@ -59,13 +59,27 @@ class DeploymentSourceUtils { } class DeploymentSource { - async getDeployments(skipLog?: boolean): Promise { + async getDeployments(options?: { + skipLog?: boolean; + limit?: number; + marker?: string | null; + }): Promise { + const params: string[] = []; + if (options?.marker) { + params.push(`marker=${encodeURIComponent(options.marker)}`); + } + if (options?.limit !== undefined) { + params.push(`limit=${options.limit}`); + } + const queryString = params.length > 0 ? `?${params.join("&")}` : ""; const response = await Api.send({ - url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/deployments`, - skipLog, + url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/deployments${queryString}`, + skipLog: options?.skipLog, }); const deployments = response.data.deployments; - DeploymentSourceUtils.sortDeployments(deployments); + if (options?.limit === undefined) { + DeploymentSourceUtils.sortDeployments(deployments); + } return deployments; } diff --git a/src/sources/TransferSource.ts b/src/sources/TransferSource.ts index 66d2a85f..5bc52a59 100644 --- a/src/sources/TransferSource.ts +++ b/src/sources/TransferSource.ts @@ -104,17 +104,29 @@ export class TransferSourceUtils { } class TransferSource { - async getTransfers( - skipLog?: boolean, - quietError?: boolean, - ): Promise { + async getTransfers(options?: { + skipLog?: boolean; + quietError?: boolean; + limit?: number; + marker?: string | null; + }): Promise { + const params: string[] = []; + if (options?.marker) { + params.push(`marker=${encodeURIComponent(options.marker)}`); + } + if (options?.limit !== undefined) { + params.push(`limit=${options.limit}`); + } + const queryString = params.length > 0 ? `?${params.join("&")}` : ""; const response = await Api.send({ - url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/transfers`, - skipLog, - quietError, + url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/transfers${queryString}`, + skipLog: options?.skipLog, + quietError: options?.quietError, }); const transfers: TransferItem[] = response.data.transfers; - TransferSourceUtils.sortTransfers(transfers); + if (options?.limit === undefined) { + TransferSourceUtils.sortTransfers(transfers); + } return transfers; } From 93c9dad9d47a5a72a18bcb0709283017187a3046 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 13 May 2026 11:49:13 +0300 Subject: [PATCH 2/6] Add server-side pagination state to TransferStore and DeploymentStore Signed-off-by: Mihaela Balutoiu --- src/stores/DeploymentStore.ts | 49 ++++++++++++++++++++++++++++-- src/stores/TransferStore.ts | 56 +++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/stores/DeploymentStore.ts b/src/stores/DeploymentStore.ts index 2f24080e..85c8cdb7 100644 --- a/src/stores/DeploymentStore.ts +++ b/src/stores/DeploymentStore.ts @@ -36,8 +36,37 @@ class DeploymentStore { @observable detailsLoading = true; + @observable deploymentsPage = 1; + + @observable deploymentsHasNextPage = false; + + @observable deploymentsItemsPerPage = 25; + deploymentsLoaded = false; + private deploymentPageMarkers: (string | null)[] = [null]; + + @action resetDeploymentPagination(): void { + this.deploymentsPage = 1; + this.deploymentsHasNextPage = false; + this.deploymentPageMarkers = [null]; + } + + @action async setDeploymentsPage(page: number): Promise { + this.deploymentsPage = page; + await this.getDeployments({ showLoading: true }); + } + + @action async setDeploymentsItemsPerPage( + itemsPerPage: number, + ): Promise { + this.deploymentsItemsPerPage = itemsPerPage; + this.deploymentsPage = 1; + this.deploymentPageMarkers = [null]; + this.deploymentsHasNextPage = false; + await this.getDeployments({ showLoading: true }); + } + @action async getDeployments(options?: { showLoading?: boolean; skipLog?: boolean; @@ -47,11 +76,25 @@ class DeploymentStore { } try { - const deployments = await DeploymentSource.getDeployments( - options && options.skipLog, - ); + const marker = + this.deploymentPageMarkers[this.deploymentsPage - 1] ?? null; + const raw = await DeploymentSource.getDeployments({ + skipLog: options?.skipLog, + limit: this.deploymentsItemsPerPage + 1, + marker, + }); + const hasNextPage = raw.length > this.deploymentsItemsPerPage; + const deployments = hasNextPage + ? raw.slice(0, this.deploymentsItemsPerPage) + : raw; + const nextMarker = + deployments.length > 0 ? deployments[deployments.length - 1].id : null; runInAction(() => { this.deployments = deployments; + this.deploymentsHasNextPage = hasNextPage; + if (nextMarker !== null) { + this.deploymentPageMarkers[this.deploymentsPage] = nextMarker; + } this.loading = false; this.deploymentsLoaded = true; }); diff --git a/src/stores/TransferStore.ts b/src/stores/TransferStore.ts index 1c660555..5b10e079 100644 --- a/src/stores/TransferStore.ts +++ b/src/stores/TransferStore.ts @@ -69,10 +69,37 @@ class TransferStore { @observable transfersWithDisksLoading = false; + @observable transfersPage = 1; + + @observable transfersHasNextPage = false; + + @observable transfersItemsPerPage = 25; + transfersLoaded = false; + private transferPageMarkers: (string | null)[] = [null]; + addExecution: { transferId: string; execution: Execution } | null = null; + @action resetTransferPagination(): void { + this.transfersPage = 1; + this.transfersHasNextPage = false; + this.transferPageMarkers = [null]; + } + + @action async setTransfersPage(page: number): Promise { + this.transfersPage = page; + await this.getTransfers({ showLoading: true }); + } + + @action async setTransfersItemsPerPage(itemsPerPage: number): Promise { + this.transfersItemsPerPage = itemsPerPage; + this.transfersPage = 1; + this.transferPageMarkers = [null]; + this.transfersHasNextPage = false; + await this.getTransfers({ showLoading: true }); + } + @action async getTransfers(options?: { showLoading?: boolean; skipLog?: boolean; @@ -85,11 +112,20 @@ class TransferStore { } try { - const transfers = await TransferSource.getTransfers( - options && options.skipLog, - options && options.quietError, - ); - this.getTransfersSuccess(transfers); + const marker = this.transferPageMarkers[this.transfersPage - 1] ?? null; + const raw = await TransferSource.getTransfers({ + skipLog: options?.skipLog, + quietError: options?.quietError, + limit: this.transfersItemsPerPage + 1, + marker, + }); + const hasNextPage = raw.length > this.transfersItemsPerPage; + const transfers = hasNextPage + ? raw.slice(0, this.transfersItemsPerPage) + : raw; + const nextMarker = + transfers.length > 0 ? transfers[transfers.length - 1].id : null; + this.getTransfersSuccess(transfers, hasNextPage, nextMarker); } finally { this.getTransfersDone(); } @@ -141,9 +177,17 @@ class TransferStore { this.currentlyLoadingExecution = ""; } - @action getTransfersSuccess(transfers: TransferItem[]) { + @action getTransfersSuccess( + transfers: TransferItem[], + hasNextPage = false, + nextMarker: string | null = null, + ) { this.transfersLoaded = true; this.transfers = transfers; + this.transfersHasNextPage = hasNextPage; + if (nextMarker !== null) { + this.transferPageMarkers[this.transfersPage] = nextMarker; + } } @action getTransfersDone() { From 2c962a1e22bb99030d1ab057372a2a9d5cd86f12 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 13 May 2026 11:51:08 +0300 Subject: [PATCH 3/6] Add support API-side pagination in FilterList and NumberedPagination Signed-off-by: Mihaela Balutoiu --- .../ui/Lists/FilterList/FilterList.spec.tsx | 103 ++++++++++++++++++ .../ui/Lists/FilterList/FilterList.tsx | 52 ++++++++- .../NumberedPagination.spec.tsx | 47 ++++++++ .../NumberedPagination/NumberedPagination.tsx | 9 +- 4 files changed, 204 insertions(+), 7 deletions(-) diff --git a/src/components/ui/Lists/FilterList/FilterList.spec.tsx b/src/components/ui/Lists/FilterList/FilterList.spec.tsx index 2d92e54c..5500df64 100644 --- a/src/components/ui/Lists/FilterList/FilterList.spec.tsx +++ b/src/components/ui/Lists/FilterList/FilterList.spec.tsx @@ -87,6 +87,35 @@ const FilterListWrap = (options?: { /> ); +type ApiPaginationOptions = { + currentPage?: number; + hasNextPage?: boolean; + itemsPerPage?: number; + onPageChange?: (page: number) => void; + onItemsPerPageChange?: (e: React.ChangeEvent) => void; + items?: typeof ITEMS; +}; + +const FilterListApiPaginationWrap = (opts: ApiPaginationOptions = {}) => ( + {}} + onItemClick={() => {}} + selectionLabel="test item" + renderItemComponent={ItemComponent} + apiPagination={{ + currentPage: opts.currentPage ?? 1, + hasNextPage: opts.hasNextPage ?? false, + itemsPerPage: opts.itemsPerPage ?? 2, + onPageChange: opts.onPageChange ?? (() => {}), + onItemsPerPageChange: opts.onItemsPerPageChange ?? (() => {}), + }} + /> +); + describe("FilterList", () => { beforeAll(() => { window.HTMLElement.prototype.scrollTo = jest.fn(); @@ -159,6 +188,80 @@ describe("FilterList", () => { expect(onItemClick).toHaveBeenCalledWith(ITEMS[1]); }); + describe("API pagination mode", () => { + it("renders all items without client-side slicing", () => { + render(); + const listItems = TestUtils.selectAll("FilterListspec__MainListItem-"); + expect(listItems).toHaveLength(ITEMS.length); + }); + + it("shows 'Page X of X+1' when hasNextPage is true (at least one more page)", () => { + render( + , + ); + expect( + TestUtils.select("NumberedPagination__PageNumber")?.textContent, + ).toBe("Page 2 of 3"); + }); + + it("shows 'Page X of X' when hasNextPage is false (current page is last)", () => { + render( + , + ); + expect( + TestUtils.select("NumberedPagination__PageNumber")?.textContent, + ).toBe("Page 2 of 2"); + }); + + it("disables Next when hasNextPage is false", () => { + render(); + const nextButton = Array.from(document.querySelectorAll("button")).find( + btn => btn.textContent === "Next", + ); + expect(nextButton).toHaveProperty("disabled", true); + }); + + it("enables Next when hasNextPage is true", () => { + render(); + const nextButton = Array.from(document.querySelectorAll("button")).find( + btn => btn.textContent === "Next", + ); + expect(nextButton).not.toHaveProperty("disabled", true); + }); + + it("calls onPageChange with next page when Next is clicked", () => { + const onPageChange = jest.fn(); + render( + , + ); + const nextButton = Array.from(document.querySelectorAll("button")).find( + btn => btn.textContent === "Next", + )!; + fireEvent.click(nextButton); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it("calls onPageChange with previous page when Previous is clicked", () => { + const onPageChange = jest.fn(); + render( + , + ); + const prevButton = Array.from(document.querySelectorAll("button")).find( + btn => btn.textContent === "Previous", + )!; + fireEvent.click(prevButton); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + }); + it("selects items", async () => { const onSelectedItemsChange = jest.fn(); render(FilterListWrap({ onSelectedItemsChange })); diff --git a/src/components/ui/Lists/FilterList/FilterList.tsx b/src/components/ui/Lists/FilterList/FilterList.tsx index dcb4169d..f1048430 100644 --- a/src/components/ui/Lists/FilterList/FilterList.tsx +++ b/src/components/ui/Lists/FilterList/FilterList.tsx @@ -40,6 +40,13 @@ const Footer = styled.div` `; type DictItem = { value: string; label: string }; +export type ApiPaginationProps = { + currentPage: number; + hasNextPage: boolean; + itemsPerPage: number; + onPageChange: (page: number) => void; + onItemsPerPageChange: (event: React.ChangeEvent) => void; +}; type Props = { items: any[]; dropdownActions?: DropdownAction[]; @@ -67,6 +74,7 @@ type Props = { listHeaderComponent?: React.ReactNode; itemsPerPageOptions?: number[]; initialItemsPerPage?: number; + apiPagination?: ApiPaginationProps; }; type State = { items: any[]; @@ -137,6 +145,9 @@ class FilterList extends React.Component { } get paginatedItems() { + if (this.props.apiPagination) { + return this.state.items; + } let paginatedItems = this.state.items; if (paginatedItems.length > this.state.itemsPerPage) { paginatedItems = this.state.items.filter( @@ -259,12 +270,20 @@ class FilterList extends React.Component { } handlePageClick = (page: number) => { - this.setPageAndItemsPerPage(page); + if (this.props.apiPagination) { + this.props.apiPagination.onPageChange(page); + } else { + this.setPageAndItemsPerPage(page); + } }; handleItemsPerPageChange = (event: React.ChangeEvent) => { - const itemsPerPage = parseInt(event.target.value, 10); - this.setPageAndItemsPerPage(1, itemsPerPage); + if (this.props.apiPagination) { + this.props.apiPagination.onItemsPerPageChange(event); + } else { + const itemsPerPage = parseInt(event.target.value, 10); + this.setPageAndItemsPerPage(1, itemsPerPage); + } }; getFooterText() { @@ -278,6 +297,27 @@ class FilterList extends React.Component { } renderPagination() { + const { apiPagination } = this.props; + + if (apiPagination) { + const { currentPage, hasNextPage, itemsPerPage } = apiPagination; + const apiItemsCount = hasNextPage + ? (currentPage + 1) * itemsPerPage + : currentPage * itemsPerPage; + return ( + + ); + } + if (this.state.items.length === 0) { return null; } @@ -336,7 +376,9 @@ class FilterList extends React.Component { showEmptyList={ this.state.items.length === 0 && this.state.filterStatus === "all" && - this.state.filterText === "" + this.state.filterText === "" && + (!this.props.apiPagination || + this.props.apiPagination.currentPage === 1) } emptyListImage={this.props.emptyListImage} emptyListMessage={this.props.emptyListMessage} @@ -345,7 +387,7 @@ class FilterList extends React.Component { emptyListComponent={this.props.emptyListComponent} onEmptyListButtonClick={this.props.onEmptyListButtonClick} /> - {this.state.items.length > 0 && ( + {(this.state.items.length > 0 || this.props.apiPagination) && (