diff --git a/cypress/support/routeSelectors.ts b/cypress/support/routeSelectors.ts index 1be8a7b7..15a853e9 100644 --- a/cypress/support/routeSelectors.ts +++ b/cypress/support/routeSelectors.ts @@ -4,11 +4,11 @@ export const routeSelectors = { AUTH_TOKENS: "**/identity/auth/tokens", CONN_SCHEMA_OPENSTACK: "**/coriolis/**/providers/openstack/schemas/16", ENDPOINTS: "**/coriolis/**/endpoints", - DEPLOYMENTS: "**/coriolis/**/deployments", + DEPLOYMENTS: "**/coriolis/**/deployments*", PROJECTS: "**/identity/auth/projects", PROVIDERS: "**/coriolis/**/providers", REGIONS: "**/coriolis/**/regions", - TRANSFERS: "**/coriolis/**/transfers", + TRANSFERS: "**/coriolis/**/transfers*", ROLE_ASSIGNMENTS: "**/identity/role_assignments*", SCHEDULES: "**/coriolis/**/transfers/**/schedules", SECRETS: "**/barbican/**/secrets", diff --git a/src/components/modules/TransferModule/Executions/Executions.spec.tsx b/src/components/modules/TransferModule/Executions/Executions.spec.tsx index 6e3f1d0f..727074c3 100644 --- a/src/components/modules/TransferModule/Executions/Executions.spec.tsx +++ b/src/components/modules/TransferModule/Executions/Executions.spec.tsx @@ -231,6 +231,81 @@ describe("Executions", () => { expect(TestUtils.select("Executions__LoadingWrapper")).toBeTruthy(); }); + it("triggers onLoadOlderExecutions when left arrow is clicked at the first execution", async () => { + const onLoadOlderExecutions = jest.fn(); + render( + , + ); + const previousArrow = TestUtils.selectAll( + "Arrow__Wrapper", + TestUtils.select("Timeline__Wrapper")!, + )[0]; + await act(async () => { + previousArrow.click(); + }); + expect(onLoadOlderExecutions).toHaveBeenCalled(); + }); + + it("does not trigger onLoadOlderExecutions when not at the first execution", async () => { + const onLoadOlderExecutions = jest.fn(); + render( + , + ); + const nextArrow = TestUtils.selectAll( + "Arrow__Wrapper", + TestUtils.select("Timeline__Wrapper")!, + )[1]; + await act(async () => { + nextArrow.click(); + }); + const previousArrow = TestUtils.selectAll( + "Arrow__Wrapper", + TestUtils.select("Timeline__Wrapper")!, + )[0]; + await act(async () => { + previousArrow.click(); + }); + expect(onLoadOlderExecutions).not.toHaveBeenCalled(); + }); + + it("triggers onLoadOlderExecutions when the oldest bullet is clicked", async () => { + const onLoadOlderExecutions = jest.fn(); + render( + , + ); + const timelineItem = TestUtils.select("Timeline__Item-"); + expect(timelineItem).toBeTruthy(); + await act(async () => { + timelineItem!.click(); + }); + expect(onLoadOlderExecutions).toHaveBeenCalled(); + }); + + it("navigates to newest of prepended older executions after load", () => { + const { rerender } = render(); + const olderExec = { ...EXECUTION_MOCK, id: "older-id", number: 0 }; + rerender( + , + ); + expect(defaultProps.onChange).toHaveBeenLastCalledWith(olderExec.id); + }); + it("deletes execution", async () => { const deleteExecution = jest.fn(); render( diff --git a/src/components/modules/TransferModule/Executions/Executions.tsx b/src/components/modules/TransferModule/Executions/Executions.tsx index daf6954a..09c2bb97 100644 --- a/src/components/modules/TransferModule/Executions/Executions.tsx +++ b/src/components/modules/TransferModule/Executions/Executions.tsx @@ -89,6 +89,8 @@ type Props = { loading: boolean; tasksLoading: boolean; instancesDetails: Instance[]; + hasOlderExecutions?: boolean; + onLoadOlderExecutions?: () => void; onChange: (executionId: string) => void; onCancelExecutionClick: ( execution: Execution | null, @@ -146,6 +148,22 @@ class Executions extends React.Component { } } } + + const prevFirstId = + this.props.executions.length > 0 + ? this.props.executions[0].id + : undefined; + const newFirstId = + props.executions.length > 0 ? props.executions[0].id : undefined; + if ( + props.executions.length > this.props.executions.length && + prevFirstId !== undefined && + prevFirstId !== newFirstId && + !(lastExecution && lastExecution.status === "RUNNING") + ) { + const newCount = props.executions.length - this.props.executions.length; + selectExecution = props.executions[newCount - 1]; + } } const currentSelectedExecution = this.state.selectedExecution; if (!currentSelectedExecution) { @@ -213,6 +231,9 @@ class Executions extends React.Component { ); if (selectedIndex === 0) { + if (this.props.hasOlderExecutions && this.props.onLoadOlderExecutions) { + this.props.onLoadOlderExecutions(); + } return; } @@ -251,6 +272,14 @@ class Executions extends React.Component { this.setState({ selectedExecution: item }, () => { this.handleChange(item); }); + + if ( + item.id === this.props.executions[0]?.id && + this.props.hasOlderExecutions && + this.props.onLoadOlderExecutions + ) { + this.props.onLoadOlderExecutions(); + } } handleCancelExecutionClick() { @@ -283,6 +312,7 @@ class Executions extends React.Component { { this.handlePreviousExecutionClick(); }} diff --git a/src/components/modules/TransferModule/Timeline/Timeline.tsx b/src/components/modules/TransferModule/Timeline/Timeline.tsx index 3824c397..231f0715 100644 --- a/src/components/modules/TransferModule/Timeline/Timeline.tsx +++ b/src/components/modules/TransferModule/Timeline/Timeline.tsx @@ -84,6 +84,7 @@ const ItemLabel = styled.div` type Props = { items?: Execution[] | null; selectedItem?: Execution | null; + hasOlderItems?: boolean; onPreviousClick?: () => void; onNextClick?: () => void; onItemClick?: (item: Execution) => void; @@ -216,7 +217,11 @@ class Timeline extends React.Component { > diff --git a/src/components/modules/TransferModule/TransferDetailsContent/TransferDetailsContent.tsx b/src/components/modules/TransferModule/TransferDetailsContent/TransferDetailsContent.tsx index 830f6aed..5ef7daf9 100644 --- a/src/components/modules/TransferModule/TransferDetailsContent/TransferDetailsContent.tsx +++ b/src/components/modules/TransferModule/TransferDetailsContent/TransferDetailsContent.tsx @@ -93,6 +93,8 @@ type Props = { executionsTasks: ExecutionTasks[]; minionPools: MinionPool[]; storageBackends: StorageBackend[]; + hasOlderExecutions?: boolean; + onLoadOlderExecutions?: () => void; onExecutionChange: (executionId: string) => void; onCancelExecutionClick: ( execution: Execution | null, @@ -212,6 +214,8 @@ class TransferDetailsContent extends React.Component { onChange={this.props.onExecutionChange} tasksLoading={this.props.executionsTasksLoading} instancesDetails={this.props.instancesDetails} + hasOlderExecutions={this.props.hasOlderExecutions} + onLoadOlderExecutions={this.props.onLoadOlderExecutions} /> ); } diff --git a/src/components/smart/DeploymentsPage/DeploymentsPage.tsx b/src/components/smart/DeploymentsPage/DeploymentsPage.tsx index a5731ed0..d2741c07 100644 --- a/src/components/smart/DeploymentsPage/DeploymentsPage.tsx +++ b/src/components/smart/DeploymentsPage/DeploymentsPage.tsx @@ -67,6 +67,8 @@ class DeploymentsPage extends React.Component { componentDidMount() { document.title = "Coriolis Deployments"; + deploymentStore.resetDeploymentPagination(); + projectStore.getProjects(); endpointStore.getEndpoints({ showLoading: true }); userStore.getAllUsers({ @@ -112,6 +114,7 @@ class DeploymentsPage extends React.Component { } handleProjectChange() { + deploymentStore.resetDeploymentPagination(); endpointStore.getEndpoints({ showLoading: true }); deploymentStore.getDeployments({ showLoading: true }); } @@ -297,6 +300,19 @@ class DeploymentsPage extends React.Component { this.setState({ selectedDeployments }); }} dropdownActions={BulkActions} + apiPagination={{ + currentPage: deploymentStore.deploymentsPage, + hasNextPage: deploymentStore.deploymentsHasNextPage, + itemsPerPage: deploymentStore.deploymentsItemsPerPage, + onPageChange: page => { + deploymentStore.setDeploymentsPage(page); + }, + onItemsPerPageChange: e => { + deploymentStore.setDeploymentsItemsPerPage( + parseInt(e.target.value, 10), + ); + }, + }} renderItemComponent={options => ( { componentDidMount() { document.title = "Transfer Details"; + transferStore.resetExecutionsPagination(); const loadTransfer = async () => { await endpointStore.getEndpoints({ showLoading: true }); @@ -114,6 +115,9 @@ class TransferDetailsPage extends React.Component { if (!this.transfer) { return; } + if (this.props.match.params.page === "executions") { + transferStore.getTransferExecutions({ showLoading: true }); + } const sourceEndpoint = endpointStore.endpoints.find( e => e.id === this.transfer!.origin_endpoint_id, ); @@ -177,17 +181,30 @@ class TransferDetailsPage extends React.Component { UNSAFE_componentWillReceiveProps(newProps: Props) { if (newProps.match.params.id !== this.props.match.params.id) { + transferStore.resetExecutionsPagination(); this.loadTransferWithInstances({ cache: true, transferId: newProps.match.params.id, + onDetailsLoaded: () => { + if (newProps.match.params.page === "executions") { + transferStore.getTransferExecutions({ showLoading: true }); + } + }, }); scheduleStore.getSchedules(newProps.match.params.id); + } else if ( + newProps.match.params.page === "executions" && + this.props.match.params.page !== "executions" + ) { + transferStore.resetExecutionsPagination(); + transferStore.getTransferExecutions({ showLoading: true }); } } componentWillUnmount() { transferStore.cancelTransferDetails(); transferStore.clearDetails(); + transferStore.resetExecutionsPagination(); scheduleStore.clearUnsavedSchedules(); this.stopPolling = true; } @@ -626,10 +643,22 @@ class TransferDetailsPage extends React.Component { }), (async () => { if (window.location.pathname.indexOf("executions") > -1) { - await transferStore.getExecutionTasks({ - transferId: this.transferId, - polling: true, - }); + const currentId = transferStore.currentlyLoadingExecution; + const currentExec = currentId + ? transferStore.executionsList.find(e => e.id === currentId) + : null; + // Only poll tasks for active executions — completed/cancelled tasks never change + if ( + currentExec && + (currentExec.status === "RUNNING" || + currentExec.status === "CANCELLING" || + currentExec.status === "AWAITING_MINION_ALLOCATIONS") + ) { + await transferStore.getExecutionTasks({ + transferId: this.transferId, + polling: true, + }); + } } })(), ]); @@ -830,12 +859,17 @@ class TransferDetailsPage extends React.Component { } executionsLoading={ transferStore.startingExecution || - transferStore.transferDetailsLoading + transferStore.transferDetailsLoading || + transferStore.executionsLoading } onExecutionChange={id => { this.handleExecutionChange(id); }} - executions={transferStore.transferDetails?.executions || []} + executions={transferStore.executionsList} + hasOlderExecutions={transferStore.executionsHasOlderPage} + onLoadOlderExecutions={() => { + transferStore.loadOlderExecutions(); + }} executionsTasksLoading={ transferStore.executionsTasksLoading || transferStore.transferDetailsLoading || diff --git a/src/components/smart/TransfersPage/TransfersPage.tsx b/src/components/smart/TransfersPage/TransfersPage.tsx index b2a8ddfa..9bce5ea4 100644 --- a/src/components/smart/TransfersPage/TransfersPage.tsx +++ b/src/components/smart/TransfersPage/TransfersPage.tsx @@ -89,6 +89,8 @@ class TransfersPage extends React.Component { componentDidMount() { document.title = "Coriolis Transfers"; + transferStore.resetTransferPagination(); + projectStore.getProjects(); endpointStore.getEndpoints({ showLoading: true }); userStore.getAllUsers({ @@ -124,6 +126,7 @@ class TransfersPage extends React.Component { } handleProjectChange() { + transferStore.resetTransferPagination(); transferStore.getTransfers(); endpointStore.getEndpoints({ showLoading: true }); } @@ -445,6 +448,19 @@ class TransfersPage extends React.Component { onPaginatedItemsChange={paginatedTransfers => { this.handlePaginatedItemsChange(paginatedTransfers); }} + apiPagination={{ + currentPage: transferStore.transfersPage, + hasNextPage: transferStore.transfersHasNextPage, + itemsPerPage: transferStore.transfersItemsPerPage, + onPageChange: page => { + transferStore.setTransfersPage(page); + }, + onItemsPerPageChange: e => { + transferStore.setTransfersItemsPerPage( + parseInt(e.target.value, 10), + ); + }, + }} renderItemComponent={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) && (