diff --git a/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md b/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md new file mode 100644 index 00000000000..e206f329fb2 --- /dev/null +++ b/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md @@ -0,0 +1,28 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dcm': minor +'@red-hat-developer-hub/backstage-plugin-dcm-common': minor +--- + +Add server-side cursor pagination to all tabs and harden pagination handling across APIs. + +**All six tabs now use server-side cursor pagination** + +Previously only Service Types, Catalog Items, and Catalog Item Instances fetched data page-by-page from the backend. Providers, Policies, and Resources loaded everything in a single call and silently lost records beyond the first page. + +- **Providers** and **Policies** — converted to `usePaginatedCrudTab` (same pattern as Catalog Items). Next / Previous buttons appear below the table; search filters the current page client-side without an extra round-trip. +- **Resources** — converted to `usePaginatedFetch` (same as Service Types). Retains the same read-only layout. + +**`dcm-common`: new pagination utilities and updated API interfaces** + +- `buildPaginationQuery` extracted from `CatalogClient` into `dcm-common/src/utils/buildPaginationQuery.ts` and exported publicly so all clients can share the same URL-builder. +- `ProvidersApi` / `ProvidersClient` — `listProviders` now accepts an optional `PaginationParams` argument. +- `PolicyManagerApi` / `PolicyManagerClient` — `listPolicies` now accepts an optional `PaginationParams` argument. +- `ServiceTypeList`, `CatalogItemList`, `CatalogItemInstanceList` — `next_page_token` is now optional (`?`) to match the real backend behaviour where the field is absent (not just empty) when there is only one page of results. + +**Dropdown options loaded once on mount** + +Service-type dropdown loads (used in the Providers and Catalog Items create/edit forms) are now fetched once on component mount via a dedicated `useEffect`, not on every page navigation. The request uses `max_page_size: 100` to avoid silently truncating valid options. + +**Test coverage** + +Added `ProvidersTabContent.test.tsx` and `ResourcesTabContent.test.tsx` with full cursor navigation test suites (initial load, error/retry, Next/Previous button states and token passing). Updated `PoliciesTabContent.test.tsx` with equivalent cursor navigation tests and refreshed mock return types. diff --git a/workspaces/dcm/plugins/dcm-common/report.api.md b/workspaces/dcm/plugins/dcm-common/report.api.md index 15f4709dd78..6106ad3eeb5 100644 --- a/workspaces/dcm/plugins/dcm-common/report.api.md +++ b/workspaces/dcm/plugins/dcm-common/report.api.md @@ -7,6 +7,9 @@ import { BasicPermission } from '@backstage/plugin-permission-common'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import type { FetchApi } from '@backstage/core-plugin-api'; +// @public +export function buildPaginationQuery(params: PaginationParams): string; + // @public export interface CatalogApi { // (undocumented) @@ -30,11 +33,13 @@ export interface CatalogApi { // (undocumented) getServiceType(serviceTypeId: string): Promise; // (undocumented) - listCatalogItemInstances(): Promise; + listCatalogItemInstances( + params?: PaginationParams, + ): Promise; // (undocumented) - listCatalogItems(): Promise; + listCatalogItems(params?: PaginationParams): Promise; // (undocumented) - listServiceTypes(): Promise; + listServiceTypes(params?: PaginationParams): Promise; rehydrateCatalogItemInstance( catalogItemInstanceId: string, ): Promise; @@ -68,11 +73,13 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // (undocumented) getServiceType(serviceTypeId: string): Promise; // (undocumented) - listCatalogItemInstances(): Promise; + listCatalogItemInstances( + params?: PaginationParams, + ): Promise; // (undocumented) - listCatalogItems(): Promise; + listCatalogItems(params?: PaginationParams): Promise; // (undocumented) - listServiceTypes(): Promise; + listServiceTypes(params?: PaginationParams): Promise; // (undocumented) rehydrateCatalogItemInstance( catalogItemInstanceId: string, @@ -126,7 +133,7 @@ export interface CatalogItemInstance { // @public export interface CatalogItemInstanceList { // (undocumented) - next_page_token: string; + next_page_token?: string; // (undocumented) results: CatalogItemInstance[]; } @@ -142,7 +149,7 @@ export interface CatalogItemInstanceSpec { // @public export interface CatalogItemList { // (undocumented) - next_page_token: string; + next_page_token?: string; // (undocumented) results: CatalogItem[]; } @@ -278,6 +285,12 @@ export interface ListServiceTypeInstancesParams { show_deleted?: boolean; } +// @public +export interface PaginationParams { + max_page_size?: number; + page_token?: string; +} + // @public export function parseDcmEntityStatus(raw: string): DcmEntityStatus | undefined; @@ -319,7 +332,7 @@ export interface PolicyManagerApi { // (undocumented) getPolicy(policyId: string): Promise; // (undocumented) - listPolicies(): Promise; + listPolicies(params?: PaginationParams): Promise; // (undocumented) updatePolicy(policyId: string, patch: Partial): Promise; } @@ -336,7 +349,7 @@ export class PolicyManagerClient // (undocumented) getPolicy(policyId: string): Promise; // (undocumented) - listPolicies(): Promise; + listPolicies(params?: PaginationParams): Promise; // (undocumented) protected readonly serviceName = 'Policy Manager'; // (undocumented) @@ -403,7 +416,7 @@ export interface ProvidersApi { // (undocumented) getProvider(providerId: string): Promise; // (undocumented) - listProviders(): Promise; + listProviders(params?: PaginationParams): Promise; } // @public @@ -417,7 +430,7 @@ export class ProvidersClient extends DcmBaseClient implements ProvidersApi { // (undocumented) getProvider(providerId: string): Promise; // (undocumented) - listProviders(): Promise; + listProviders(params?: PaginationParams): Promise; // (undocumented) protected readonly serviceName = 'Providers'; } @@ -507,7 +520,7 @@ export interface ServiceTypeInstanceSpec { // @public export interface ServiceTypeList { // (undocumented) - next_page_token: string; + next_page_token?: string; // (undocumented) results: ServiceType[]; } diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts index a12fc771166..4cc3bb8fc2e 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts @@ -22,6 +22,7 @@ import type { ServiceType, ServiceTypeList, } from '../types/catalog'; +import type { PaginationParams } from '../types/common'; /** * Interface for the DCM Catalog API client. @@ -30,12 +31,12 @@ import type { */ export interface CatalogApi { // Service Types - listServiceTypes(): Promise; + listServiceTypes(params?: PaginationParams): Promise; getServiceType(serviceTypeId: string): Promise; createServiceType(serviceType: ServiceType): Promise; // Catalog Items - listCatalogItems(): Promise; + listCatalogItems(params?: PaginationParams): Promise; getCatalogItem(catalogItemId: string): Promise; createCatalogItem(catalogItem: CatalogItem): Promise; updateCatalogItem( @@ -45,7 +46,9 @@ export interface CatalogApi { deleteCatalogItem(catalogItemId: string): Promise; // Catalog Item Instances - listCatalogItemInstances(): Promise; + listCatalogItemInstances( + params?: PaginationParams, + ): Promise; getCatalogItemInstance( catalogItemInstanceId: string, ): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts index 2bc2e3500b2..727b0edc01c 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts @@ -22,6 +22,8 @@ import type { ServiceType, ServiceTypeList, } from '../types/catalog'; +import type { PaginationParams } from '../types/common'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; import type { CatalogApi } from './CatalogApi'; import { DcmBaseClient } from './DcmBaseClient'; @@ -39,8 +41,12 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // ── Service Types ────────────────────────────────────────────────────────── - async listServiceTypes(): Promise { - return this.fetch('service-types'); + async listServiceTypes( + params: PaginationParams = {}, + ): Promise { + return this.fetch( + `service-types${buildPaginationQuery(params)}`, + ); } async getServiceType(serviceTypeId: string): Promise { @@ -56,8 +62,12 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // ── Catalog Items ────────────────────────────────────────────────────────── - async listCatalogItems(): Promise { - return this.fetch('catalog-items'); + async listCatalogItems( + params: PaginationParams = {}, + ): Promise { + return this.fetch( + `catalog-items${buildPaginationQuery(params)}`, + ); } async getCatalogItem(catalogItemId: string): Promise { @@ -90,8 +100,12 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // ── Catalog Item Instances ───────────────────────────────────────────────── - async listCatalogItemInstances(): Promise { - return this.fetch('catalog-item-instances'); + async listCatalogItemInstances( + params: PaginationParams = {}, + ): Promise { + return this.fetch( + `catalog-item-instances${buildPaginationQuery(params)}`, + ); } async getCatalogItemInstance( diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts index 584ad728252..a0b60482ed3 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Policy, PolicyList } from '../types/policy-manager'; /** @@ -22,7 +23,7 @@ import type { Policy, PolicyList } from '../types/policy-manager'; * @public */ export interface PolicyManagerApi { - listPolicies(): Promise; + listPolicies(params?: PaginationParams): Promise; getPolicy(policyId: string): Promise; createPolicy(policy: Policy): Promise; updatePolicy(policyId: string, patch: Partial): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts index ab1fa58c6f3..4cf1d360327 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Policy, PolicyList } from '../types/policy-manager'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; import type { PolicyManagerApi } from './PolicyManagerApi'; import { DcmBaseClient } from './DcmBaseClient'; @@ -33,8 +35,8 @@ export class PolicyManagerClient { protected readonly serviceName = 'Policy Manager'; - async listPolicies(): Promise { - return this.fetch('policies'); + async listPolicies(params: PaginationParams = {}): Promise { + return this.fetch(`policies${buildPaginationQuery(params)}`); } async getPolicy(policyId: string): Promise { diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts index 46d772087b4..21098fd466d 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Provider, ProviderList } from '../types/providers'; /** @@ -22,7 +23,7 @@ import type { Provider, ProviderList } from '../types/providers'; * @public */ export interface ProvidersApi { - listProviders(): Promise; + listProviders(params?: PaginationParams): Promise; getProvider(providerId: string): Promise; createProvider(provider: Provider): Promise; applyProvider(providerId: string, provider: Provider): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts index 9c2f315b673..4431698d835 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts @@ -59,6 +59,19 @@ describe('ProvidersClient', () => { ); }); + it('listProviders appends max_page_size and page_token query params', async () => { + const fetchFn = jest + .fn() + .mockResolvedValue(okJson({ providers: [MOCK_PROVIDER] })); + const client = makeClient(fetchFn); + + await client.listProviders({ max_page_size: 10, page_token: 'tok-1' }); + + const [url] = fetchFn.mock.calls[0]; + expect(url).toContain('max_page_size=10'); + expect(url).toContain('page_token=tok-1'); + }); + it('getProvider calls GET /providers/{id}', async () => { const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_PROVIDER)); const client = makeClient(fetchFn); diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts index 8b26331ae76..db626323920 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Provider, ProviderList } from '../types/providers'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; import type { ProvidersApi } from './ProvidersApi'; import { DcmBaseClient } from './DcmBaseClient'; @@ -30,8 +32,8 @@ import { DcmBaseClient } from './DcmBaseClient'; export class ProvidersClient extends DcmBaseClient implements ProvidersApi { protected readonly serviceName = 'Providers'; - async listProviders(): Promise { - return this.fetch('providers'); + async listProviders(params: PaginationParams = {}): Promise { + return this.fetch(`providers${buildPaginationQuery(params)}`); } async getProvider(providerId: string): Promise { diff --git a/workspaces/dcm/plugins/dcm-common/src/index.ts b/workspaces/dcm/plugins/dcm-common/src/index.ts index 4d6618c21d6..8ab9b9cb802 100644 --- a/workspaces/dcm/plugins/dcm-common/src/index.ts +++ b/workspaces/dcm/plugins/dcm-common/src/index.ts @@ -38,3 +38,4 @@ export * from './types'; export * from './clients'; export { DcmClientError } from './errors/DcmClientError'; export { extractApiError } from './utils/extractApiError'; +export { buildPaginationQuery } from './utils/buildPaginationQuery'; diff --git a/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts b/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts index 44a8dc2ee22..3218a9a6d0d 100644 --- a/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts +++ b/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts @@ -99,17 +99,17 @@ export interface UserValue { /** Paginated list of {@link ServiceType} resources. */ export interface ServiceTypeList { results: ServiceType[]; - next_page_token: string; + next_page_token?: string; } /** Paginated list of {@link CatalogItem} resources. */ export interface CatalogItemList { results: CatalogItem[]; - next_page_token: string; + next_page_token?: string; } /** Paginated list of {@link CatalogItemInstance} resources. */ export interface CatalogItemInstanceList { results: CatalogItemInstance[]; - next_page_token: string; + next_page_token?: string; } diff --git a/workspaces/dcm/plugins/dcm-common/src/types/common.ts b/workspaces/dcm/plugins/dcm-common/src/types/common.ts index d665715cfd4..1570d3b2ae6 100644 --- a/workspaces/dcm/plugins/dcm-common/src/types/common.ts +++ b/workspaces/dcm/plugins/dcm-common/src/types/common.ts @@ -50,3 +50,16 @@ export interface DcmHealth { status: string; path?: string; } + +/** + * Query parameters shared by all DCM list endpoints that support + * cursor-based pagination (AEP-158). + * + * @public + */ +export interface PaginationParams { + /** Maximum number of results to return in one page (1–100, default 100). */ + max_page_size?: number; + /** Opaque page token returned by a previous list response. */ + page_token?: string; +} diff --git a/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts new file mode 100644 index 00000000000..47fd3726b35 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { buildPaginationQuery } from './buildPaginationQuery'; + +describe('buildPaginationQuery', () => { + it('returns empty string when no params are provided', () => { + expect(buildPaginationQuery({})).toBe(''); + }); + + it('returns only max_page_size when only size is provided', () => { + expect(buildPaginationQuery({ max_page_size: 10 })).toBe( + '?max_page_size=10', + ); + }); + + it('returns only page_token when only token is provided', () => { + expect(buildPaginationQuery({ page_token: 'tok-abc' })).toBe( + '?page_token=tok-abc', + ); + }); + + it('returns both params when both are provided', () => { + const result = buildPaginationQuery({ + max_page_size: 25, + page_token: 'tok-xyz', + }); + expect(result).toContain('max_page_size=25'); + expect(result).toContain('page_token=tok-xyz'); + expect(result).toMatch(/^\?/); + }); + + it('omits page_token when it is an empty string', () => { + expect(buildPaginationQuery({ page_token: '' })).toBe(''); + }); + + it('omits max_page_size when it is undefined', () => { + expect( + buildPaginationQuery({ max_page_size: undefined, page_token: 'tok' }), + ).toBe('?page_token=tok'); + }); +}); diff --git a/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts new file mode 100644 index 00000000000..d1489bf0899 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts @@ -0,0 +1,32 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PaginationParams } from '../types/common'; + +/** + * Builds a URL query string from pagination params. + * Returns an empty string when no params are set. + * + * @public + */ +export function buildPaginationQuery(params: PaginationParams): string { + const q = new URLSearchParams(); + if (params.max_page_size !== undefined) + q.set('max_page_size', String(params.max_page_size)); + if (params.page_token) q.set('page_token', params.page_token); + const qs = q.toString(); + return qs ? `?${qs}` : ''; +} diff --git a/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx b/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx new file mode 100644 index 00000000000..73abc0cd222 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Table, type TableColumn } from '@backstage/core-components'; +import { Box, IconButton, MenuItem, Select, Tooltip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { useTranslation } from '../hooks/useTranslation'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + gap: theme.spacing(0.5), + padding: theme.spacing(1, 2), + borderTop: `1px solid ${theme.palette.divider}`, + }, +})); + +export type CursorPaginationControlsProps = Readonly<{ + hasNext: boolean; + hasPrev: boolean; + onNext: () => void; + onPrev: () => void; + /** Disables both buttons while a page fetch is in progress. */ + loading?: boolean; + /** Currently selected page size. Required when `onPageSizeChange` is provided. */ + pageSize?: number; + /** Called with the newly selected page size. When omitted, the size selector is hidden. */ + onPageSizeChange?: (size: number) => void; + /** Options shown in the page-size dropdown. Defaults to [5, 15, 25]. */ + pageSizeOptions?: number[]; +}>; + +/** + * Previous / Next navigation row for cursor-based (server-side) pagination. + * Rendered below a table when the Backstage ``'s built-in pager is + * disabled (`paging: false`). + * + * When `onPageSizeChange` and `pageSize` are provided a rows-per-page selector + * is rendered to the left of the navigation buttons. + */ +export function CursorPaginationControls({ + hasNext, + hasPrev, + onNext, + onPrev, + loading = false, + pageSize, + onPageSizeChange, + pageSizeOptions = [5, 10, 25], +}: CursorPaginationControlsProps) { + const classes = useStyles(); + const { t } = useTranslation(); + + return ( + + {onPageSizeChange !== undefined && pageSize !== undefined && ( + + )} + + + + + + + + + + + + + + + + ); +} + +/** + * A `
` in cursor-pagination mode (built-in pager disabled) with + * {@link CursorPaginationControls} rendered directly below it. + * + * Use this wherever cursor-based server pagination is enabled to avoid + * duplicating the table-options + controls wiring. + */ +export function CursorPaginatedTable({ + data, + columns, + pagination, +}: Readonly<{ + data: T[]; + columns: TableColumn[]; + pagination: CursorPaginationControlsProps; +}>) { + return ( + <> + + data={data} + columns={columns} + options={{ + paging: false, + search: false, + sorting: true, + padding: 'default', + toolbar: false, + emptyRowsWhenPaging: false, + }} + /> + + + ); +} diff --git a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx index de6ee8b95a0..69cad39df60 100644 --- a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx @@ -33,6 +33,7 @@ import MuiAlert from '@material-ui/lab/Alert'; import type { BoxProps } from '@material-ui/core/Box'; import { DcmDataCenterTabEmptyState } from './DcmDataCenterTabEmptyState'; import { DcmSearchCardAction } from './dcmTabListHelpers'; +import { CursorPaginatedTable } from './CursorPaginationControls'; import { useDcmStyles } from './dcmStyles'; import { useTranslation } from '../hooks/useTranslation'; @@ -60,11 +61,27 @@ export type DcmCrudTabLayoutProps = Readonly<{ search: string; onSearchChange: Dispatch>; - // ── Pagination ─────────────────────────────────────────────────────────── - page: number; - pageSize: number; - onPageChange: (page: number, pageSize: number) => void; - onRowsPerPageChange: (pageSize: number) => void; + // ── Client-side pagination (mutually exclusive with cursorPagination) ──── + page?: number; + pageSize?: number; + onPageChange?: (page: number, pageSize: number) => void; + onRowsPerPageChange?: (pageSize: number) => void; + + /** + * When provided, server-side cursor-based pagination is used instead of the + * Backstage Table's built-in pager. The table is rendered with `paging: + * false` and {@link CursorPaginationControls} is shown below it. + */ + cursorPagination?: { + hasNext: boolean; + hasPrev: boolean; + onNext: () => void; + onPrev: () => void; + loading?: boolean; + pageSize?: number; + onPageSizeChange?: (size: number) => void; + pageSizeOptions?: number[]; + }; // ── Empty state ────────────────────────────────────────────────────────── emptyTitle: string; @@ -131,10 +148,11 @@ export function DcmCrudTabLayout({ onDismissActionError, search, onSearchChange, - page, - pageSize, + page = 1, + pageSize = 5, onPageChange, onRowsPerPageChange, + cursorPagination, emptyTitle, emptyDescription, primaryActionLabel, @@ -169,7 +187,11 @@ export function DcmCrudTabLayout({ ); } - if (items.length === 0) { + // Show global empty-state only when we are certain the dataset is truly + // empty (i.e. not just an empty cursor page on page 2+). If hasPrev is true + // the user deleted the last row on a non-first page — fall through to the + // table view so cursor controls remain accessible. + if (items.length === 0 && !cursorPagination?.hasPrev) { return ( <> {actionError && ( @@ -198,7 +220,9 @@ export function DcmCrudTabLayout({ ({ /> )} - - data={paginated} - columns={columns} - options={{ - paging: true, - pageSize, - pageSizeOptions: [5, 10, 25], - search: false, - sorting: true, - padding: 'default', - toolbar: false, - /** Avoid blank rows padding the table to `pageSize` when fewer rows exist. */ - emptyRowsWhenPaging: false, - }} - totalCount={filtered.length} - page={page} - onPageChange={onPageChange} - onRowsPerPageChange={onRowsPerPageChange} - localization={{ - pagination: { labelRowsPerPage: t('common.rows') }, - }} - /> + {cursorPagination ? ( + + data={filtered} + columns={columns} + pagination={cursorPagination} + /> + ) : ( + + data={paginated} + columns={columns} + options={{ + paging: true, + pageSize, + pageSizeOptions: [5, 10, 25], + search: false, + sorting: true, + padding: 'default', + toolbar: false, + /** Avoid blank rows padding the table to `pageSize` when fewer rows exist. */ + emptyRowsWhenPaging: false, + }} + totalCount={filtered.length} + page={Math.max(0, page - 1)} + onPageChange={ + onPageChange ? (p, ps) => onPageChange(p + 1, ps) : undefined + } + onRowsPerPageChange={onRowsPerPageChange} + localization={{ + pagination: { labelRowsPerPage: t('common.rows') }, + }} + /> + )} diff --git a/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx b/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx index e54bd23e38a..243db528d78 100644 --- a/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx @@ -30,6 +30,10 @@ import DeleteIcon from '@material-ui/icons/Delete'; import EditIcon from '@material-ui/icons/Edit'; import SearchIcon from '@material-ui/icons/Search'; import { useTranslation } from '../hooks/useTranslation'; +import { + CursorPaginatedTable, + type CursorPaginationControlsProps, +} from './CursorPaginationControls'; const useStyles = makeStyles({ filterInput: { minWidth: 200 }, @@ -107,12 +111,15 @@ function ActionsCell({ onEdit, onDelete }: ActionsCellProps) { ); } +/** Alias kept for backwards compatibility — use {@link CursorPaginationControlsProps} directly when possible. */ +export type CursorPaginationProps = CursorPaginationControlsProps; + export type DcmSearchTableCardProps = Readonly<{ title: string; /** Already-paginated rows to render. */ data: T[]; columns: TableColumn[]; - /** Total rows after filtering (drives the pagination footer). */ + /** Total rows after filtering (drives the pagination footer). Ignored in cursor mode. */ totalCount: number; page: number; pageSize: number; @@ -121,6 +128,11 @@ export type DcmSearchTableCardProps = Readonly<{ search: string; setSearch: Dispatch>; pageSizeOptions?: number[]; + /** + * When provided, disables the Table's built-in pager and renders + * {@link CursorPaginationControls} below the table instead. + */ + cursorPagination?: CursorPaginationProps; }>; /** @@ -140,6 +152,7 @@ export function DcmSearchTableCard({ search, setSearch, pageSizeOptions = [5, 10, 25], + cursorPagination, }: DcmSearchTableCardProps) { const classes = useDcmStyles(); const { t } = useTranslation(); @@ -157,31 +170,41 @@ export function DcmSearchTableCard({ titleTypographyProps={{ className: classes.cardTitle }} > - - data={data} - columns={columns} - options={{ - paging: true, - pageSize, - pageSizeOptions, - search: false, - sorting: true, - padding: 'default', - toolbar: false, - emptyRowsWhenPaging: false, - }} - totalCount={totalCount} - page={page} - onPageChange={(p, ps) => { - setPage(p); - setPageSize(ps); - }} - onRowsPerPageChange={ps => { - setPageSize(ps); - setPage(0); - }} - localization={{ pagination: { labelRowsPerPage: t('common.rows') } }} - /> + {cursorPagination ? ( + + data={data} + columns={columns} + pagination={cursorPagination} + /> + ) : ( + + data={data} + columns={columns} + options={{ + paging: true, + pageSize, + pageSizeOptions, + search: false, + sorting: true, + padding: 'default', + toolbar: false, + emptyRowsWhenPaging: false, + }} + totalCount={totalCount} + page={Math.max(0, page - 1)} + onPageChange={(p, ps) => { + setPage(p + 1); + setPageSize(ps); + }} + onRowsPerPageChange={ps => { + setPageSize(ps); + setPage(1); + }} + localization={{ + pagination: { labelRowsPerPage: t('common.rows') }, + }} + /> + )} ); diff --git a/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.test.ts b/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.test.ts index 3ce86e1a6cd..069313c96ad 100644 --- a/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.test.ts +++ b/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.test.ts @@ -330,4 +330,49 @@ describe('useCrudTab', () => { expect(result.current.items).toHaveLength(3); }); }); + + describe('server-side pagination (PagedLoadResult)', () => { + it('extracts items and nextPageToken from a PagedLoadResult', async () => { + const opts = makeOptions({ + loadFn: jest.fn().mockResolvedValue({ + items: [...ITEMS], + nextPageToken: 'page2token', + }), + }); + const { result } = renderHook(() => useCrudTab(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toHaveLength(3); + expect(result.current.nextPageToken).toBe('page2token'); + }); + + it('clears nextPageToken when last page is returned (empty token)', async () => { + const opts = makeOptions({ + loadFn: jest.fn().mockResolvedValue({ + items: [...ITEMS], + nextPageToken: '', + }), + }); + const { result } = renderHook(() => useCrudTab(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.nextPageToken).toBe(''); + }); + + it('nextPageToken defaults to empty string for plain array loadFn', async () => { + const { result } = renderHook(() => + useCrudTab(makeOptions()), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.nextPageToken).toBe(''); + }); + + it('clears nextPageToken on load failure', async () => { + const opts = makeOptions({ + loadFn: jest.fn().mockRejectedValue(new Error('fail')), + }); + const { result } = renderHook(() => useCrudTab(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.nextPageToken).toBe(''); + expect(result.current.loadError).toBe('fail'); + }); + }); }); diff --git a/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.ts b/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.ts index ae8a5463546..ae67c90130a 100644 --- a/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.ts +++ b/workspaces/dcm/plugins/dcm/src/hooks/useCrudTab.ts @@ -46,9 +46,20 @@ function removeItemById( * @template T - The domain entity type (e.g. Provider, Policy) * @template F - The form state type (e.g. ProviderForm) */ +/** Result shape returned by server-side-paginated load functions. */ +export interface PagedLoadResult { + items: T[]; + /** Opaque cursor returned by the API. Empty string or undefined means no next page. */ + nextPageToken?: string; +} + export interface UseCrudTabOptions> { - /** Fetches all items from the API. May also set secondary state as a side effect. */ - loadFn: () => Promise; + /** + * Fetches items from the API. Returns either a plain array (client-side + * pagination) or a {@link PagedLoadResult} object when server-side + * cursor-based pagination is in use. + */ + loadFn: () => Promise>; /** * Creates a new item. Receives the raw form so the caller can apply any * transformation or pass extra params (e.g. a client-assigned ID). @@ -109,6 +120,11 @@ export interface UseCrudTabResult> { refreshing: boolean; loadError: string | null; reload: () => void; + /** + * Opaque cursor token for the next page, populated when `loadFn` returns a + * {@link PagedLoadResult}. Empty string means no next page or client-side mode. + */ + nextPageToken: string; // ── Search + pagination ──────────────────────────────────────────────────── search: string; @@ -196,6 +212,7 @@ export function useCrudTab>( const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [loadError, setLoadError] = useState(null); + const [nextPageToken, setNextPageToken] = useState(''); const hasLoadedRef = useRef(false); // ── Search + pagination ────────────────────────────────────────────────── @@ -263,12 +280,19 @@ export function useCrudTab>( optsRef.current .loadFn() .then(result => { - setItems(result); + if (Array.isArray(result)) { + setItems(result); + setNextPageToken(''); + } else { + setItems(result.items); + setNextPageToken(result.nextPageToken ?? ''); + } hasLoadedRef.current = true; }) .catch(err => { setLoadError(extractApiError(err)); setItems([]); + setNextPageToken(''); hasLoadedRef.current = false; }) .finally(() => { @@ -444,6 +468,7 @@ export function useCrudTab>( refreshing, loadError, reload, + nextPageToken, // Search + pagination search, diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.test.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.test.ts new file mode 100644 index 00000000000..a22e117d2b8 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.test.ts @@ -0,0 +1,271 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + usePaginatedCrudTab, + UsePaginatedCrudTabOptions, +} from './usePaginatedCrudTab'; +import type { PaginatedLoadParams } from './usePaginatedCrudTab'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +type Item = { id: string; name: string }; +type Form = { name: string; [key: string]: unknown }; + +const PAGE_1: Item[] = [ + { id: '1', name: 'Alpha' }, + { id: '2', name: 'Beta' }, +]; +const PAGE_2: Item[] = [{ id: '3', name: 'Gamma' }]; + +const STORAGE_KEY = 'test-hook'; + +function makeOptions( + overrides?: Partial>, +): UsePaginatedCrudTabOptions { + return { + loadFn: jest + .fn() + .mockResolvedValue({ items: [...PAGE_1], nextPageToken: 'tok2' }), + createFn: jest + .fn() + .mockImplementation((form: Form) => + Promise.resolve({ id: '99', name: form.name } as Item), + ), + deleteFn: jest.fn().mockResolvedValue(undefined), + getId: (item: Item) => item.id, + getSearchText: (item: Item) => [item.name], + emptyForm: () => ({ name: '' } as Form), + isValid: (form: Form) => Boolean(form.name?.trim()), + storageKey: STORAGE_KEY, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('usePaginatedCrudTab', () => { + beforeEach(() => { + localStorage.clear(); + }); + + // ── Initial load ──────────────────────────────────────────────────────────── + + describe('initial load', () => { + it('calls loadFn with pageToken=undefined and the persisted page size', async () => { + const opts = makeOptions(); + renderHook(() => usePaginatedCrudTab(opts)); + + await waitFor(() => expect(opts.loadFn as jest.Mock).toHaveBeenCalled()); + + const firstCall = ( + (opts.loadFn as jest.Mock).mock.calls[0] as [PaginatedLoadParams] + )[0]; + expect(firstCall.pageToken).toBeUndefined(); + expect(firstCall.pageSize).toBeGreaterThan(0); + }); + + it('populates items and sets hasNext when nextPageToken is returned', async () => { + const opts = makeOptions(); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.items).toHaveLength(PAGE_1.length); + expect(result.current.cursorPagination.hasNext).toBe(true); + expect(result.current.cursorPagination.hasPrev).toBe(false); + }); + + it('hasPrev is false on the first page', async () => { + const opts = makeOptions(); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasPrev).toBe(false); + }); + }); + + // ── goNext ────────────────────────────────────────────────────────────────── + + describe('goNext', () => { + it('passes the nextPageToken to loadFn and sets hasPrev=true', async () => { + const loadFn = jest + .fn() + .mockResolvedValueOnce({ items: [...PAGE_1], nextPageToken: 'tok2' }) + .mockResolvedValueOnce({ items: [...PAGE_2], nextPageToken: '' }); + + const opts = makeOptions({ loadFn }); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasNext).toBe(true); + + act(() => result.current.goNext()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + const secondCallArgs = (loadFn.mock.calls[1] as [PaginatedLoadParams])[0]; + expect(secondCallArgs.pageToken).toBe('tok2'); + + expect(result.current.cursorPagination.hasPrev).toBe(true); + expect(result.current.cursorPagination.hasNext).toBe(false); + expect(result.current.items).toHaveLength(PAGE_2.length); + }); + }); + + // ── goPrev ────────────────────────────────────────────────────────────────── + + describe('goPrev', () => { + it('returns to the first page token and shrinks the token stack', async () => { + const loadFn = jest + .fn() + .mockResolvedValueOnce({ items: [...PAGE_1], nextPageToken: 'tok2' }) + .mockResolvedValueOnce({ items: [...PAGE_2], nextPageToken: '' }) + .mockResolvedValueOnce({ items: [...PAGE_1], nextPageToken: 'tok2' }); + + const opts = makeOptions({ loadFn }); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + // load page 1 + await waitFor(() => expect(result.current.loading).toBe(false)); + + // go to page 2 + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasPrev).toBe(true); + + // go back to page 1 + act(() => result.current.goPrev()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const thirdCallArgs = (loadFn.mock.calls[2] as [PaginatedLoadParams])[0]; + expect(thirdCallArgs.pageToken).toBeUndefined(); + + expect(result.current.cursorPagination.hasPrev).toBe(false); + expect(result.current.items).toHaveLength(PAGE_1.length); + }); + }); + + // ── handlePageSizeChange ──────────────────────────────────────────────────── + + describe('handlePageSizeChange', () => { + it('resets to page 1 (undefined token) and reloads with the new size', async () => { + const loadFn = jest + .fn() + .mockResolvedValueOnce({ items: [...PAGE_1], nextPageToken: 'tok2' }) + .mockResolvedValueOnce({ items: [...PAGE_2], nextPageToken: '' }) + .mockResolvedValue({ items: [...PAGE_1], nextPageToken: '' }); + + const opts = makeOptions({ loadFn }); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // navigate to page 2 to build up a token stack + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasPrev).toBe(true); + + const newSize = 25; + act(() => result.current.handlePageSizeChange(newSize)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // cursor must be reset + const lastCallArgs = ( + loadFn.mock.calls[loadFn.mock.calls.length - 1] as [PaginatedLoadParams] + )[0]; + expect(lastCallArgs.pageToken).toBeUndefined(); + expect(lastCallArgs.pageSize).toBe(newSize); + + // hasPrev must be gone — token stack cleared + expect(result.current.cursorPagination.hasPrev).toBe(false); + }); + + it('persists the new page size to localStorage', async () => { + const opts = makeOptions(); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.handlePageSizeChange(15)); + + expect(localStorage.getItem(`dcm:pageSize:${STORAGE_KEY}`)).toBe('15'); + }); + }); + + // ── handleSearchChange ────────────────────────────────────────────────────── + + describe('handleSearchChange', () => { + it('filters the current page client-side without re-calling loadFn', async () => { + const loadFn = jest + .fn() + .mockResolvedValue({ items: [...PAGE_1], nextPageToken: 'tok2' }); + const opts = makeOptions({ loadFn }); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + const callsBefore = (loadFn as jest.Mock).mock.calls.length; + + act(() => result.current.handleSearchChange('alpha')); + + // loadFn must NOT have been called again + expect((loadFn as jest.Mock).mock.calls).toHaveLength(callsBefore); + + // Only "Alpha" should pass the filter + expect(result.current.filtered).toHaveLength(1); + expect(result.current.filtered[0].name).toBe('Alpha'); + }); + + it('leaves the cursor token stack untouched', async () => { + const loadFn = jest + .fn() + .mockResolvedValueOnce({ items: [...PAGE_1], nextPageToken: 'tok2' }) + .mockResolvedValueOnce({ items: [...PAGE_2], nextPageToken: '' }); + + const opts = makeOptions({ loadFn }); + const { result } = renderHook(() => + usePaginatedCrudTab(opts), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // advance to page 2 so token stack is non-empty + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.cursorPagination.hasPrev).toBe(true); + + act(() => result.current.handleSearchChange('gamma')); + + // hasPrev (token stack) must be unchanged + expect(result.current.cursorPagination.hasPrev).toBe(true); + }); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts new file mode 100644 index 00000000000..1d44ceb62eb --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts @@ -0,0 +1,203 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useRef, useState } from 'react'; +import { + useCrudTab, + type PagedLoadResult, + type UseCrudTabOptions, + type UseCrudTabResult, +} from './useCrudTab'; +import { usePersistedPageSize } from './usePersistedPageSize'; +import type { CursorPaginationControlsProps } from '../components/CursorPaginationControls'; + +/** Parameters injected into the load function on every page request. */ +export interface PaginatedLoadParams { + /** Opaque cursor for the current page, or undefined for the first page. */ + pageToken: string | undefined; + /** Number of items to request per page. */ + pageSize: number; +} + +/** + * Options for {@link usePaginatedCrudTab}. + * + * Extends {@link UseCrudTabOptions} but replaces the no-arg `loadFn` with one + * that receives `{ pageToken, pageSize }`, and makes `storageKey` required (the + * hook always persists the page size). + */ +export interface UsePaginatedCrudTabOptions< + T, + F extends Record, +> extends Omit, 'loadFn' | 'storageKey'> { + /** + * Fetches one page of items. Receives cursor params so the implementation + * never needs to manage pagination refs directly. Return a plain array for + * client-side pagination or a {@link PagedLoadResult} for server cursor mode. + */ + loadFn: (params: PaginatedLoadParams) => Promise>; + /** + * `localStorage` key used to persist the selected page size (e.g. + * `'providers'`, `'policies'`). Must be unique per table. + */ + storageKey: string; +} + +/** Alias kept for backwards compatibility — use {@link CursorPaginationControlsProps} directly when possible. */ +export type CursorPaginationProps = CursorPaginationControlsProps; + +/** + * Result returned by {@link usePaginatedCrudTab}. + * + * Everything from {@link UseCrudTabResult} plus pre-built navigation helpers + * and a `cursorPagination` object ready to be spread onto `DcmCrudTabLayout`. + */ +export interface UsePaginatedCrudTabResult> + extends UseCrudTabResult { + goNext: () => void; + goPrev: () => void; + /** + * Drop-in replacement for `crud.setSearch`. Search is client-side filtering + * on the loaded page; cursor state (Prev/Next) is unchanged. + */ + handleSearchChange: (value: React.SetStateAction) => void; + /** + * Changes the page size, resets cursor navigation to page 1, and reloads. + * Updates `localStorage` via {@link usePersistedPageSize}. + */ + handlePageSizeChange: (size: number) => void; + /** Ready-made object for the `cursorPagination` prop of `DcmCrudTabLayout`. */ + cursorPagination: CursorPaginationProps; +} + +/** + * Wrapper around {@link useCrudTab} that adds server-side cursor pagination. + * + * Encapsulates the token-stack pattern so individual tab components no longer + * need to manage `currentTokenRef`, `tokenStackRef`, `goNext`, `goPrev`, or + * `handleSearchChange` themselves. + * + * @example + * const crud = usePaginatedCrudTab({ + * loadFn: ({ pageToken, pageSize }) => + * providersApi.listProviders({ page_token: pageToken, max_page_size: pageSize }) + * .then(r => ({ items: r.providers ?? [], nextPageToken: r.next_page_token })), + * storageKey: 'providers', + * createFn: form => providersApi.createProvider(form), + * ... + * }); + * + * // In JSX: + * + */ +export function usePaginatedCrudTab>( + options: UsePaginatedCrudTabOptions, +): UsePaginatedCrudTabResult { + // Destructure storageKey so it is NOT forwarded to useCrudTab, which would + // create a second usePersistedPageSize call on the same localStorage key. + const { storageKey, ...crudOptions } = options; + + const [pageSize, setPageSize] = usePersistedPageSize(storageKey); + const pageSizeRef = useRef(pageSize); + pageSizeRef.current = pageSize; + + // Token for the CURRENT page. Updated via ref synchronously before reload. + const currentTokenRef = useRef(undefined); + + // Stack of tokens for previously-visited pages (enables Previous navigation). + const [tokenStack, setTokenStack] = useState([]); + const tokenStackRef = useRef([]); + + // Keep the latest options in a ref so the stable loadFn wrapper always + // calls the freshest implementation without needing it in its dep array. + const optsRef = useRef(options); + optsRef.current = options; + + const crud = useCrudTab({ + ...crudOptions, + loadFn: () => + optsRef.current.loadFn({ + pageToken: currentTokenRef.current, + pageSize: pageSizeRef.current, + }), + }); + + const { nextPageToken, reload: crudReload, setSearch: setCrudSearch } = crud; + + // ── Cursor navigation ──────────────────────────────────────────────────── + + const goNext = useCallback(() => { + const tokenToPush = currentTokenRef.current ?? ''; + currentTokenRef.current = nextPageToken || undefined; + tokenStackRef.current = [...tokenStackRef.current, tokenToPush]; + setTokenStack(tokenStackRef.current); + crudReload(); + }, [nextPageToken, crudReload]); + + const goPrev = useCallback(() => { + const stack = tokenStackRef.current; + const prevToken = stack.at(-1); + currentTokenRef.current = prevToken || undefined; + tokenStackRef.current = stack.slice(0, -1); + setTokenStack(tokenStackRef.current); + crudReload(); + }, [crudReload]); + + // When the page size changes, update the ref immediately (so the next reload + // uses the new size without waiting for a re-render), reset the cursor to the + // first page, and trigger a reload. + const handlePageSizeChange = useCallback( + (newSize: number) => { + pageSizeRef.current = newSize; + setPageSize(newSize); + currentTokenRef.current = undefined; + tokenStackRef.current = []; + setTokenStack([]); + crudReload(); + }, + [setPageSize, crudReload], + ); + + // Search is client-side filtering on the already-loaded page; cursor state + // (Prev/Next) stays tied to the server page that was fetched. + const handleSearchChange = useCallback( + (value: React.SetStateAction) => { + setCrudSearch(value); + }, + [setCrudSearch], + ); + + return { + ...crud, + goNext, + goPrev, + handleSearchChange, + handlePageSizeChange, + cursorPagination: { + hasNext: Boolean(nextPageToken), + hasPrev: tokenStack.length > 0, + onNext: goNext, + onPrev: goPrev, + loading: crud.loading || crud.refreshing, + pageSize, + onPageSizeChange: handlePageSizeChange, + }, + }; +} diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts new file mode 100644 index 00000000000..ebc749572f3 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts @@ -0,0 +1,256 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + usePaginatedFetch, + UsePaginatedFetchOptions, +} from './usePaginatedFetch'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +type Item = { id: string }; + +function makePages(total: number, pageSize: number): Item[][] { + const all: Item[] = Array.from({ length: total }, (_, i) => ({ + id: String(i + 1), + })); + const pages: Item[][] = []; + for (let i = 0; i < all.length; i += pageSize) { + pages.push(all.slice(i, i + pageSize)); + } + return pages; +} + +function makeOptions( + pages: Item[][], + overrides?: Partial>, +): UsePaginatedFetchOptions { + return { + fetchFn: jest.fn().mockImplementation(({ pageToken }) => { + const idx = pageToken ? parseInt(pageToken, 10) : 0; + const items = pages[idx] ?? []; + const nextIdx = idx + 1; + const nextPageToken = nextIdx < pages.length ? String(nextIdx) : ''; + return Promise.resolve({ items, nextPageToken }); + }), + pageSize: 5, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('usePaginatedFetch', () => { + describe('initial load', () => { + it('starts in loading state', () => { + const opts = makeOptions(makePages(5, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + expect(result.current.loading).toBe(true); + }); + + it('populates data after load', async () => { + const opts = makeOptions(makePages(5, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toHaveLength(5); + expect(result.current.hasPrev).toBe(false); + expect(result.current.hasNext).toBe(false); + }); + + it('exposes hasNext when more pages exist', async () => { + const opts = makeOptions(makePages(10, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasNext).toBe(true); + expect(result.current.hasPrev).toBe(false); + }); + + it('sets error and clears data on failure', async () => { + const opts = makeOptions([], { + fetchFn: jest.fn().mockRejectedValue(new Error('network error')), + }); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('network error'); + expect(result.current.data).toHaveLength(0); + }); + }); + + describe('goNext', () => { + it('fetches the next page and enables hasPrev', async () => { + const opts = makeOptions(makePages(15, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data[0].id).toBe('1'); + + act(() => { + result.current.goNext(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data[0].id).toBe('6'); + expect(result.current.hasPrev).toBe(true); + expect(result.current.hasNext).toBe(true); + }); + + it('navigating to the last page clears hasNext', async () => { + const opts = makeOptions(makePages(10, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.goNext(); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasNext).toBe(false); + expect(result.current.hasPrev).toBe(true); + }); + }); + + describe('goPrev', () => { + it('goes back to the first page', async () => { + const opts = makeOptions(makePages(10, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data[0].id).toBe('6'); + + act(() => result.current.goPrev()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data[0].id).toBe('1'); + expect(result.current.hasPrev).toBe(false); + }); + + it('navigates across three pages and back', async () => { + const opts = makeOptions(makePages(15, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data[0].id).toBe('11'); + expect(result.current.hasNext).toBe(false); + + act(() => result.current.goPrev()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data[0].id).toBe('6'); + + act(() => result.current.goPrev()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data[0].id).toBe('1'); + expect(result.current.hasPrev).toBe(false); + }); + }); + + describe('refresh', () => { + it('re-fetches the current page without changing cursor position', async () => { + const fetchFn = jest.fn().mockImplementation(({ pageToken }) => { + const idx = pageToken ? parseInt(pageToken, 10) : 0; + const pages = makePages(10, 5); + const items = pages[idx] ?? []; + const nextIdx = idx + 1; + const nextPageToken = nextIdx < pages.length ? String(nextIdx) : ''; + return Promise.resolve({ items, nextPageToken }); + }); + const { result } = renderHook(() => + usePaginatedFetch({ fetchFn, pageSize: 5 }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + const callCountAfterNext = fetchFn.mock.calls.length; + + act(() => result.current.refresh()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchFn.mock.calls).toHaveLength(callCountAfterNext + 1); + // Should still be on page 2 (pageToken '1') + expect(result.current.data[0].id).toBe('6'); + }); + }); + + describe('resetToFirstPage', () => { + it('resets cursor and fetches the first page', async () => { + const opts = makeOptions(makePages(10, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasPrev).toBe(true); + + act(() => result.current.resetToFirstPage()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data[0].id).toBe('1'); + expect(result.current.hasPrev).toBe(false); + }); + }); + + describe('search does not affect cursor state', () => { + it('hasNext remains true after search interaction (no resetCursor side-effect)', async () => { + // Page 1 of 2 — hasNext should be true after load. + const opts = makeOptions(makePages(10, 5)); + const { result } = renderHook(() => usePaginatedFetch(opts)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasNext).toBe(true); + + // Simulate a parent component calling search (consumer-side state change). + // The hook itself does not expose a setSearch; cursor state must stay intact. + // Re-render with the same options to confirm nothing is reset. + // (The regression: resetCursor() used to clear nextToken, permanently + // disabling Next until remount or page-size change.) + expect(result.current.hasNext).toBe(true); + expect(result.current.hasPrev).toBe(false); + }); + + it('goNext still works after the hook re-renders with new options (search-like rerender)', async () => { + const pages = makePages(10, 5); + const fetchFn = jest.fn().mockImplementation(({ pageToken }) => { + const idx = pageToken ? parseInt(pageToken, 10) : 0; + const items = pages[idx] ?? []; + const nextIdx = idx + 1; + const nextPageToken = nextIdx < pages.length ? String(nextIdx) : ''; + return Promise.resolve({ items, nextPageToken }); + }); + const { result, rerender } = renderHook( + ({ pageSize }: { pageSize: number }) => + usePaginatedFetch({ fetchFn, pageSize }), + { initialProps: { pageSize: 5 } }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasNext).toBe(true); + + // Simulate a re-render caused by a parent state update (e.g. search text + // change) with the same pageSize — cursor state must be preserved. + rerender({ pageSize: 5 }); + expect(result.current.hasNext).toBe(true); + + act(() => result.current.goNext()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data[0].id).toBe('6'); + expect(result.current.hasPrev).toBe(true); + }); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts new file mode 100644 index 00000000000..338430a0e54 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts @@ -0,0 +1,185 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { extractApiError } from '../utils/extractApiError'; + +/** Shape that fetch functions must return. */ +export interface PaginatedPage { + items: T[]; + /** Opaque token for the next page, or empty/undefined when no next page exists. */ + nextPageToken: string; +} + +/** Parameters passed to the fetch function on every request. */ +export interface FetchParams { + pageToken: string | undefined; + pageSize: number; +} + +export interface UsePaginatedFetchOptions { + /** Called for every page request (initial load, Next, Previous, refresh). */ + fetchFn: (params: FetchParams) => Promise>; + /** Number of items to request per page. */ + pageSize: number; +} + +export interface UsePaginatedFetchResult { + data: T[]; + loading: boolean; + error: string | null; + /** True when the last server response included a non-empty next_page_token. */ + hasNext: boolean; + /** True when we have navigated past the first page. */ + hasPrev: boolean; + goNext: () => void; + goPrev: () => void; + /** Re-fetches the current page without changing the cursor position. */ + refresh: () => void; + /** Resets cursor to the first page and re-fetches. */ + resetToFirstPage: () => void; +} + +/** + * Generic cursor-based pagination hook. + * + * Maintains a stack of previously-visited page tokens so Previous navigation + * is possible without any extra API knowledge. The fetch function is called + * with `{ pageToken, pageSize }` on every page change and on `refresh()`. + * + * The token stack stores the tokens of ALL pages visited before the current + * one. The current page token is kept in a ref so navigation handlers can + * update it synchronously before the next fetch. + * + * @example + * const { data, loading, error, hasNext, hasPrev, goNext, goPrev } = + * usePaginatedFetch({ + * fetchFn: ({ pageToken, pageSize }) => + * catalogApi.listServiceTypes({ page_token: pageToken, max_page_size: pageSize }) + * .then(r => ({ items: r.results ?? [], nextPageToken: r.next_page_token })), + * pageSize, + * }); + */ +export function usePaginatedFetch( + options: UsePaginatedFetchOptions, +): UsePaginatedFetchResult { + // Keep a ref that always reflects the latest options so the stable `fetch` + // callback never closes over stale values. Assigning inline (during render) + // rather than inside a useEffect guarantees the ref is up-to-date when a + // page-size change triggers a `resetToFirstPage` in a useEffect. + const optsRef = useRef(options); + optsRef.current = options; + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nextToken, setNextToken] = useState(''); + + // Stack of tokens for pages BEFORE the current one. Length === number of + // previously-visited pages, so hasPrev = tokenStack.length > 0. + const [tokenStack, setTokenStack] = useState([]); + + // Ref holding the token for the CURRENT page so fetch() can read it + // synchronously even when navigation state hasn't re-rendered yet. + const currentTokenRef = useRef(undefined); + + // Mirrors tokenStack in a ref so goPrev can read the latest value + // synchronously inside the click handler (before the next render). + const tokenStackRef = useRef([]); + + const fetch = useCallback(() => { + setLoading(true); + setError(null); + optsRef.current + .fetchFn({ + pageToken: currentTokenRef.current, + pageSize: optsRef.current.pageSize, + }) + .then(page => { + setData(page.items); + setNextToken(page.nextPageToken ?? ''); + }) + .catch(err => { + setError(extractApiError(err)); + setData([]); + setNextToken(''); + }) + .finally(() => setLoading(false)); + }, []); + + // Initial load + useEffect(() => { + fetch(); + }, [fetch]); + + // When pageSize changes after the initial render, reset to the first page + // automatically so consumers don't need to duplicate this effect. + const prevPageSizeRef = useRef(options.pageSize); + useEffect(() => { + if (prevPageSizeRef.current !== options.pageSize) { + prevPageSizeRef.current = options.pageSize; + currentTokenRef.current = undefined; + tokenStackRef.current = []; + setTokenStack([]); + setNextToken(''); + fetch(); + } + }, [options.pageSize, fetch]); + + const goNext = useCallback(() => { + // Capture the current-page token as a plain string BEFORE mutating the ref + // so the setTokenStack call below closes over the right value regardless of + // when React schedules the state-updater function. + const tokenToPush = currentTokenRef.current ?? ''; + currentTokenRef.current = nextToken || undefined; + tokenStackRef.current = [...tokenStackRef.current, tokenToPush]; + setTokenStack(tokenStackRef.current); + fetch(); + }, [fetch, nextToken]); + + const goPrev = useCallback(() => { + const stack = tokenStackRef.current; + const prevToken = stack.at(-1); + currentTokenRef.current = prevToken || undefined; + tokenStackRef.current = stack.slice(0, -1); + setTokenStack(tokenStackRef.current); + fetch(); + }, [fetch]); + + const refresh = useCallback(() => { + fetch(); + }, [fetch]); + + const resetToFirstPage = useCallback(() => { + currentTokenRef.current = undefined; + tokenStackRef.current = []; + setTokenStack([]); + setNextToken(''); + fetch(); + }, [fetch]); + + return { + data, + loading, + error, + hasNext: Boolean(nextToken), + hasPrev: tokenStack.length > 0, + goNext, + goPrev, + refresh, + resetToFirstPage, + }; +} diff --git a/workspaces/dcm/plugins/dcm/src/pages/catalog-item-instances/CatalogItemInstancesTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/catalog-item-instances/CatalogItemInstancesTabContent.tsx index 7913f211799..9f80274d8e4 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/catalog-item-instances/CatalogItemInstancesTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/catalog-item-instances/CatalogItemInstancesTabContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { TableColumn } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { @@ -45,7 +45,7 @@ import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; import { DcmFormDialog } from '../../components/DcmFormDialog'; import { DcmFormDialogActions } from '../../components/DcmFormDialogActions'; import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; -import { useCrudTab } from '../../hooks/useCrudTab'; +import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; import { useTranslation } from '../../hooks/useTranslation'; import emptyIllustration from '../../assets/environments-empty-state.png'; import { InstanceFormFields } from './components/InstanceFormFields'; @@ -81,21 +81,35 @@ export function CatalogItemInstancesTabContent() { const { t } = useTranslation(); const [catalogItems, setCatalogItems] = useState([]); + const [catalogItemsError, setCatalogItemsError] = useState( + null, + ); const [rehydratingId, setRehydratingId] = useState(null); const [rehydrateError, setRehydrateError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [rehydrateConfirmInst, setRehydrateConfirmInst] = useState(null); - const crud = useCrudTab({ - loadFn: async () => { - const [instanceList, itemList] = await Promise.all([ - catalogApi.listCatalogItemInstances().then(r => r.results ?? []), - catalogApi.listCatalogItems().then(r => r.results ?? []), - ]); - setCatalogItems(itemList); - return instanceList; - }, + // Fetch catalog items once on mount; page navigation does not re-fetch. + useEffect(() => { + catalogApi + .listCatalogItems({ max_page_size: 100 }) + .then(r => setCatalogItems(r.results ?? [])) + .catch(err => setCatalogItemsError(extractApiError(err))); + }, [catalogApi]); + + const crud = usePaginatedCrudTab({ + loadFn: ({ pageToken, pageSize: ps }) => + catalogApi + .listCatalogItemInstances({ + page_token: pageToken, + max_page_size: ps, + }) + .then(r => ({ + items: r.results ?? [], + nextPageToken: r.next_page_token, + })), + storageKey: 'catalog-item-instances', createFn: form => catalogApi.createCatalogItemInstance(formToInstance(form)), deleteFn: id => catalogApi.deleteCatalogItemInstance(id), @@ -108,7 +122,6 @@ export function CatalogItemInstancesTabContent() { ], emptyForm: emptyInstanceForm, isValid: isInstanceFormValid, - storageKey: 'catalog-item-instances', }); const { handleOpenDelete, setItems } = crud; @@ -276,19 +289,19 @@ export function CatalogItemInstancesTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} onRetry={crud.reload} - actionError={rehydrateError} - onDismissActionError={() => setRehydrateError(null)} + actionError={catalogItemsError ?? rehydrateError} + onDismissActionError={() => { + setCatalogItemsError(null); + setRehydrateError(null); + }} search={crud.search} - onSearchChange={crud.setSearch} - page={crud.page} - pageSize={crud.pageSize} - onPageChange={crud.onPageChange} - onRowsPerPageChange={crud.onRowsPerPageChange} + onSearchChange={crud.handleSearchChange} + cursorPagination={crud.cursorPagination} emptyTitle={t('instances.emptyTitle')} emptyDescription={t('instances.emptyDescription')} primaryActionLabel={t('instances.createButton')} diff --git a/workspaces/dcm/plugins/dcm/src/pages/catalog-items/CatalogItemsTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/catalog-items/CatalogItemsTabContent.tsx index 7d27556fec3..424d41ab52d 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/catalog-items/CatalogItemsTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/catalog-items/CatalogItemsTabContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { TableColumn } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { @@ -74,13 +74,14 @@ import type { CatalogItem, ServiceType, } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { extractApiError } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { catalogApiRef } from '../../apis'; import { DcmCrudTabLayout } from '../../components/DcmCrudTabLayout'; import { DcmDeleteDialog } from '../../components/DcmDeleteDialog'; import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; import { createEditDeleteColumn } from '../../components/dcmTabListHelpers'; import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; -import { useCrudTab } from '../../hooks/useCrudTab'; +import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; import { useTranslation } from '../../hooks/useTranslation'; import emptyIllustration from '../../assets/environments-empty-state.png'; import { CatalogItemFormFields } from './components/CatalogItemFormFields'; @@ -99,19 +100,30 @@ export function CatalogItemsTabContent() { const { t } = useTranslation(); const [serviceTypes, setServiceTypes] = useState([]); + const [serviceTypesError, setServiceTypesError] = useState( + null, + ); /** Tracks whether a submit was attempted so field-level errors show for all fields. */ const [createSubmitAttempted, setCreateSubmitAttempted] = useState(false); const [editSubmitAttempted, setEditSubmitAttempted] = useState(false); - const crud = useCrudTab({ - loadFn: async () => { - const [itemList, serviceTypeList] = await Promise.all([ - catalogApi.listCatalogItems().then(r => r.results ?? []), - catalogApi.listServiceTypes().then(r => r.results ?? []), - ]); - setServiceTypes(serviceTypeList); - return itemList; - }, + // Fetch dropdown options once on mount; page navigation does not re-fetch. + useEffect(() => { + catalogApi + .listServiceTypes({ max_page_size: 100 }) + .then(r => setServiceTypes(r.results ?? [])) + .catch(err => setServiceTypesError(extractApiError(err))); + }, [catalogApi]); + + const crud = usePaginatedCrudTab({ + loadFn: ({ pageToken, pageSize: ps }) => + catalogApi + .listCatalogItems({ page_token: pageToken, max_page_size: ps }) + .then(r => ({ + items: r.results ?? [], + nextPageToken: r.next_page_token, + })), + storageKey: 'catalog-items', createFn: form => catalogApi.createCatalogItem(formToCatalogItem(form)), updateFn: (id, form) => catalogApi.updateCatalogItem(id, formToCatalogItemForUpdate(form)), @@ -125,7 +137,6 @@ export function CatalogItemsTabContent() { emptyForm: emptyCatalogItemForm, isValid: isCatalogItemFormValid, itemToForm: catalogItemToForm, - storageKey: 'catalog-items', createSuccessMessage: t('catalogItems.createSuccess'), editSuccessMessage: t('catalogItems.updateSuccess'), deleteSuccessMessage: t('catalogItems.deleteSuccess'), @@ -321,19 +332,16 @@ export function CatalogItemsTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} onRetry={crud.reload} - actionError={null} - onDismissActionError={undefined} + actionError={serviceTypesError} + onDismissActionError={() => setServiceTypesError(null)} search={crud.search} - onSearchChange={crud.setSearch} - page={crud.page} - pageSize={crud.pageSize} - onPageChange={crud.onPageChange} - onRowsPerPageChange={crud.onRowsPerPageChange} + onSearchChange={crud.handleSearchChange} + cursorPagination={crud.cursorPagination} emptyTitle={t('catalogItems.emptyTitle')} emptyDescription={t('catalogItems.emptyDescription')} primaryActionLabel={t('catalogItems.createButton')} diff --git a/workspaces/dcm/plugins/dcm/src/pages/catalog-items/components/CatalogItemFormFields.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/catalog-items/components/CatalogItemFormFields.test.tsx index d6d8f744fca..e5a17fb6363 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/catalog-items/components/CatalogItemFormFields.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/catalog-items/components/CatalogItemFormFields.test.tsx @@ -107,9 +107,9 @@ describe('CatalogItemFormFields – file import error handling', () => { await userEvent.upload(input, file); - await waitFor(() => - expect(screen.getByText(/Failed to import file/i)).toBeInTheDocument(), - ); + expect( + await screen.findByText(/Failed to import file/i), + ).toBeInTheDocument(); }); it('does not show an error alert when a valid JSON file is imported', async () => { @@ -148,9 +148,9 @@ describe('CatalogItemFormFields – file import error handling', () => { await userEvent.upload(input, file); // Wait for error to appear - await waitFor(() => - expect(screen.getByText(/Failed to import file/i)).toBeInTheDocument(), - ); + expect( + await screen.findByText(/Failed to import file/i), + ).toBeInTheDocument(); // MuiAlert renders a close button const closeBtn = screen.getByRole('button', { name: /close/i }); diff --git a/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.test.tsx index 614a6ffac9a..6cbd8363224 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.test.tsx @@ -37,7 +37,9 @@ const MOCK_POLICY: Policy = { const UPDATED_POLICY: Policy = { ...MOCK_POLICY, enabled: false }; const baseMockApi = { - listPolicies: jest.fn().mockResolvedValue({ policies: [MOCK_POLICY] }), + listPolicies: jest + .fn() + .mockResolvedValue({ policies: [MOCK_POLICY], next_page_token: undefined }), updatePolicy: jest.fn(), createPolicy: jest.fn(), deletePolicy: jest.fn(), @@ -57,6 +59,84 @@ async function renderPoliciesTab( ); } +describe('PoliciesTabContent – pagination params', () => { + beforeEach(() => jest.clearAllMocks()); + + it('calls listPolicies with pagination params on mount', async () => { + const mockApi = buildMockApi(); + await renderPoliciesTab(mockApi); + + await waitFor(() => expect(mockApi.listPolicies).toHaveBeenCalledTimes(1)); + expect(mockApi.listPolicies).toHaveBeenCalledWith( + expect.objectContaining({ max_page_size: expect.any(Number) }), + ); + }); +}); + +describe('PoliciesTabContent – cursor pagination', () => { + beforeEach(() => jest.clearAllMocks()); + + it('shows a Next button when next_page_token is returned', async () => { + const mockApi = buildMockApi({ + listPolicies: jest.fn().mockResolvedValue({ + policies: [MOCK_POLICY], + next_page_token: 'tok-2', + }), + }); + await renderPoliciesTab(mockApi); + + expect( + await screen.findByRole('button', { name: /next/i }), + ).toBeInTheDocument(); + }); + + it('Next button is disabled when no next_page_token', async () => { + const mockApi = buildMockApi({ + listPolicies: jest + .fn() + .mockResolvedValue({ policies: [MOCK_POLICY], next_page_token: '' }), + }); + await renderPoliciesTab(mockApi); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + expect(nextBtn).toBeDisabled(); + }); + + it('Previous button is disabled on the first page', async () => { + const mockApi = buildMockApi({ + listPolicies: jest.fn().mockResolvedValue({ + policies: [MOCK_POLICY], + next_page_token: 'tok-2', + }), + }); + await renderPoliciesTab(mockApi); + + const prevBtn = await screen.findByRole('button', { name: /previous/i }); + expect(prevBtn).toBeDisabled(); + }); + + it('calls listPolicies with next_page_token after clicking Next', async () => { + const listPolicies = jest + .fn() + .mockResolvedValueOnce({ + policies: [MOCK_POLICY], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ policies: [MOCK_POLICY], next_page_token: '' }); + const mockApi = buildMockApi({ listPolicies }); + await renderPoliciesTab(mockApi); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect(listPolicies).toHaveBeenCalledWith( + expect.objectContaining({ page_token: 'tok-2' }), + ), + ); + }); +}); + describe('PoliciesTabContent – toggle error handling', () => { beforeEach(() => jest.clearAllMocks()); @@ -73,9 +153,7 @@ describe('PoliciesTabContent – toggle error handling', () => { fireEvent.click(toggleSwitch); // The snackbar should show the error text - await waitFor(() => - expect(screen.getByText(/toggle failed/i)).toBeInTheDocument(), - ); + expect(await screen.findByText(/toggle failed/i)).toBeInTheDocument(); }); it('does not show a snackbar when updatePolicy succeeds', async () => { diff --git a/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.tsx index 1891fe9b2a1..86fb96f8f4b 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/policies/PoliciesTabContent.tsx @@ -58,7 +58,7 @@ import { DcmFormDialog } from '../../components/DcmFormDialog'; import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; import { DcmFormDialogActions } from '../../components/DcmFormDialogActions'; import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; -import { useCrudTab } from '../../hooks/useCrudTab'; +import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; import { useTranslation } from '../../hooks/useTranslation'; import { extractApiError } from '../../utils/extractApiError'; import emptyIllustration from '../../assets/environments-empty-state.png'; @@ -99,8 +99,18 @@ export function PoliciesTabContent() { const [togglingIds, setTogglingIds] = useState>(new Set()); const [toggleError, setToggleError] = useState(null); - const crud = useCrudTab({ - loadFn: () => policyApi.listPolicies().then(res => res.policies ?? []), + const crud = usePaginatedCrudTab({ + loadFn: ({ pageToken, pageSize: ps }) => + policyApi + .listPolicies({ + page_token: pageToken, + max_page_size: ps, + }) + .then(res => ({ + items: res.policies ?? [], + nextPageToken: res.next_page_token, + })), + storageKey: 'policies', createFn: form => policyApi.createPolicy(formToPolicy(form)), updateFn: (id, form) => policyApi.updatePolicy(id, formToPolicy(form)), deleteFn: id => policyApi.deletePolicy(id), @@ -109,7 +119,6 @@ export function PoliciesTabContent() { emptyForm: emptyPolicyForm, isValid: isPolicyFormValid, itemToForm: policyToForm, - storageKey: 'policies', createSuccessMessage: t('policies.createSuccess'), editSuccessMessage: t('policies.updateSuccess'), deleteSuccessMessage: t('policies.deleteSuccess'), @@ -335,7 +344,7 @@ export function PoliciesTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} @@ -343,11 +352,8 @@ export function PoliciesTabContent() { actionError={toggleError} onDismissActionError={() => setToggleError(null)} search={crud.search} - onSearchChange={crud.setSearch} - page={crud.page} - pageSize={crud.pageSize} - onPageChange={crud.onPageChange} - onRowsPerPageChange={crud.onRowsPerPageChange} + onSearchChange={crud.handleSearchChange} + cursorPagination={crud.cursorPagination} emptyTitle={t('policies.emptyTitle')} emptyDescription={t('policies.emptyDescription')} primaryActionLabel={t('policies.createButton')} diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx new file mode 100644 index 00000000000..1439da39248 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx @@ -0,0 +1,293 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import type { + Provider, + ServiceType, +} from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { catalogApiRef, providersApiRef } from '../../apis'; +import { ProvidersTabContent } from './ProvidersTabContent'; + +jest.mock('../../hooks/useTranslation', () => { + const mod = require('../../test-utils/mockTranslations'); + return { useTranslation: mod.mockUseTranslation }; +}); + +const MOCK_PROVIDER: Provider = { + id: 'provider-1', + name: 'my-provider', + display_name: 'My Provider', + endpoint: 'http://example.com', + service_type: 'vm', + schema_version: 'v1alpha1', +}; + +const MOCK_SERVICE_TYPE: ServiceType = { + uid: 'st-1', + service_type: 'vm', + api_version: 'v1alpha1', + spec: {}, +}; + +const baseProvidersApi = { + listProviders: jest.fn().mockResolvedValue({ + providers: [MOCK_PROVIDER], + next_page_token: undefined, + }), + createProvider: jest.fn(), + applyProvider: jest.fn(), + deleteProvider: jest.fn(), + getProvider: jest.fn(), +}; + +const baseCatalogApi = { + listServiceTypes: jest + .fn() + .mockResolvedValue({ results: [MOCK_SERVICE_TYPE] }), + listCatalogItems: jest.fn().mockResolvedValue({ results: [] }), + listCatalogItemInstances: jest.fn().mockResolvedValue({ results: [] }), + getCatalogItem: jest.fn(), + getCatalogItemInstance: jest.fn(), + getServiceType: jest.fn(), + createServiceType: jest.fn(), + createCatalogItem: jest.fn(), + updateCatalogItem: jest.fn(), + deleteCatalogItem: jest.fn(), + createCatalogItemInstance: jest.fn(), + deleteCatalogItemInstance: jest.fn(), + rehydrateCatalogItemInstance: jest.fn(), +}; + +function buildApis( + providerOverrides: Partial = {}, + catalogOverrides: Partial = {}, +) { + return { + providers: { ...baseProvidersApi, ...providerOverrides }, + catalog: { ...baseCatalogApi, ...catalogOverrides }, + }; +} + +async function renderProvidersTab( + apis: ReturnType = buildApis(), +) { + return renderInTestApp( + + + , + ); +} + +describe('ProvidersTabContent', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('initial load', () => { + it('calls listProviders with pagination params on mount', async () => { + const apis = buildApis(); + await renderProvidersTab(apis); + + await waitFor(() => + expect(apis.providers.listProviders).toHaveBeenCalledTimes(1), + ); + expect(apis.providers.listProviders).toHaveBeenCalledWith( + expect.objectContaining({ max_page_size: expect.any(Number) }), + ); + }); + + it('calls listServiceTypes with max_page_size: 100 for the dropdown (once on mount)', async () => { + const apis = buildApis(); + await renderProvidersTab(apis); + + await waitFor(() => + expect(apis.catalog.listServiceTypes).toHaveBeenCalledTimes(1), + ); + expect(apis.catalog.listServiceTypes).toHaveBeenCalledWith({ + max_page_size: 100, + }); + }); + + it('shows provider name in the table after successful load', async () => { + const apis = buildApis(); + await renderProvidersTab(apis); + + expect(await screen.findByText('my-provider')).toBeInTheDocument(); + }); + + it('shows the empty state when no providers are returned', async () => { + const apis = buildApis({ + listProviders: jest.fn().mockResolvedValue({ providers: [] }), + }); + await renderProvidersTab(apis); + + expect( + await screen.findByText(/no providers registered/i), + ).toBeInTheDocument(); + }); + }); + + describe('load error', () => { + it('shows an error alert when listProviders rejects', async () => { + const apis = buildApis({ + listProviders: jest.fn().mockRejectedValue(new Error('API down')), + }); + await renderProvidersTab(apis); + + expect(await screen.findByText(/API down/i)).toBeInTheDocument(); + }); + + it('shows a Retry button when listProviders rejects', async () => { + const apis = buildApis({ + listProviders: jest.fn().mockRejectedValue(new Error('API down')), + }); + await renderProvidersTab(apis); + + expect( + await screen.findByRole('button', { name: /retry/i }), + ).toBeInTheDocument(); + }); + }); + + describe('cursor pagination', () => { + it('shows Next button when next_page_token is returned', async () => { + const apis = buildApis({ + listProviders: jest.fn().mockResolvedValue({ + providers: [MOCK_PROVIDER], + next_page_token: 'tok-2', + }), + }); + await renderProvidersTab(apis); + + expect( + await screen.findByRole('button', { name: /next/i }), + ).toBeInTheDocument(); + }); + + it('Next button is disabled when no next_page_token', async () => { + const apis = buildApis({ + listProviders: jest.fn().mockResolvedValue({ + providers: [MOCK_PROVIDER], + next_page_token: '', + }), + }); + await renderProvidersTab(apis); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + expect(nextBtn).toBeDisabled(); + }); + + it('calls listProviders with next_page_token after clicking Next', async () => { + const listProviders = jest + .fn() + .mockResolvedValueOnce({ + providers: [MOCK_PROVIDER], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ + providers: [MOCK_PROVIDER], + next_page_token: '', + }); + const apis = buildApis({ listProviders }); + await renderProvidersTab(apis); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect(listProviders).toHaveBeenCalledWith( + expect.objectContaining({ page_token: 'tok-2' }), + ), + ); + }); + + it('Previous button is disabled on the first page', async () => { + const apis = buildApis({ + listProviders: jest.fn().mockResolvedValue({ + providers: [MOCK_PROVIDER], + next_page_token: 'tok-2', + }), + }); + await renderProvidersTab(apis); + + const prevBtn = await screen.findByRole('button', { name: /previous/i }); + expect(prevBtn).toBeDisabled(); + }); + + it('shows the current page size in the rows-per-page selector', async () => { + const apis = buildApis(); + await renderProvidersTab(apis); + + await screen.findByText('my-provider'); + // The Select renders the selected value as "5 rows" via renderValue. + expect(screen.getByText('5 rows')).toBeInTheDocument(); + }); + + it('re-fetches with new max_page_size when the page-size option is selected', async () => { + const listProviders = jest + .fn() + .mockResolvedValue({ providers: [MOCK_PROVIDER], next_page_token: '' }); + const apis = buildApis({ listProviders }); + await renderProvidersTab(apis); + + await screen.findByText('my-provider'); + expect(listProviders).toHaveBeenCalledTimes(1); + + // Open the MUI Select by clicking its trigger button (displays current size). + // The trigger renders as a button inside the pagination controls. + fireEvent.mouseDown(screen.getByRole('button', { name: /rows/i })); + + // Pick "10" from the opened dropdown menu. + const option10 = await screen.findByRole('option', { name: '10' }); + fireEvent.click(option10); + + await waitFor(() => + expect(listProviders).toHaveBeenCalledWith( + expect.objectContaining({ max_page_size: 10 }), + ), + ); + }); + + it('Previous button is enabled after navigating to page 2', async () => { + const listProviders = jest + .fn() + .mockResolvedValueOnce({ + providers: [MOCK_PROVIDER], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ + providers: [MOCK_PROVIDER], + next_page_token: '', + }); + const apis = buildApis({ listProviders }); + await renderProvidersTab(apis); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /previous/i }), + ).not.toBeDisabled(), + ); + }); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx index 49c9220ecc9..74e2581c8de 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { TableColumn } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { Box, Chip, Tooltip, Typography } from '@material-ui/core'; @@ -41,6 +41,7 @@ import type { Provider, ServiceType, } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { extractApiError } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { catalogApiRef, providersApiRef } from '../../apis'; import { DcmCrudTabLayout } from '../../components/DcmCrudTabLayout'; import { DcmDeleteDialog } from '../../components/DcmDeleteDialog'; @@ -49,7 +50,7 @@ import { DcmSuccessSnackbar } from '../../components/DcmSuccessSnackbar'; import { DcmFormDialogActions } from '../../components/DcmFormDialogActions'; import { createEditDeleteColumn } from '../../components/dcmTabListHelpers'; import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; -import { useCrudTab } from '../../hooks/useCrudTab'; +import { usePaginatedCrudTab } from '../../hooks/usePaginatedCrudTab'; import { useTranslation } from '../../hooks/useTranslation'; import emptyIllustration from '../../assets/environments-empty-state.png'; import { CopyButton } from './components/CopyButton'; @@ -71,16 +72,27 @@ export function ProvidersTabContent() { const { t } = useTranslation(); const [serviceTypes, setServiceTypes] = useState([]); + const [serviceTypesError, setServiceTypesError] = useState( + null, + ); + + // Fetch dropdown options once on mount; page navigation does not re-fetch. + useEffect(() => { + catalogApi + .listServiceTypes({ max_page_size: 100 }) + .then(r => setServiceTypes(r.results ?? [])) + .catch(err => setServiceTypesError(extractApiError(err))); + }, [catalogApi]); - const crud = useCrudTab({ - loadFn: async () => { - const [providerList, serviceTypeList] = await Promise.all([ - providersApi.listProviders().then(r => r.providers ?? []), - catalogApi.listServiceTypes().then(r => r.results ?? []), - ]); - setServiceTypes(serviceTypeList); - return providerList; - }, + const crud = usePaginatedCrudTab({ + loadFn: ({ pageToken, pageSize: ps }) => + providersApi + .listProviders({ page_token: pageToken, max_page_size: ps }) + .then(r => ({ + items: r.providers ?? [], + nextPageToken: r.next_page_token, + })), + storageKey: 'providers', createFn: form => providersApi.createProvider(formToProvider(form)), updateFn: (id, form) => providersApi.applyProvider(id, formToProvider(form)), @@ -90,7 +102,6 @@ export function ProvidersTabContent() { emptyForm: emptyProviderForm, isValid: isProviderFormValid, itemToForm: providerToForm, - storageKey: 'providers', createSuccessMessage: t('providers.createSuccess'), editSuccessMessage: t('providers.updateSuccess'), deleteSuccessMessage: t('providers.deleteSuccess'), @@ -298,27 +309,22 @@ export function ProvidersTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} onRetry={crud.reload} - actionError={null} - onDismissActionError={undefined} + actionError={serviceTypesError} + onDismissActionError={() => setServiceTypesError(null)} search={crud.search} - onSearchChange={crud.setSearch} - page={crud.page} - pageSize={crud.pageSize} - onPageChange={crud.onPageChange} - onRowsPerPageChange={crud.onRowsPerPageChange} + onSearchChange={crud.handleSearchChange} + cursorPagination={crud.cursorPagination} emptyTitle={t('providers.emptyTitle')} emptyDescription={t('providers.emptyDescription')} primaryActionLabel={t('providers.registerButton')} onPrimaryAction={crud.handleOpenCreate} illustrationSrc={emptyIllustration} entityLabel={t('providers.entityLabel')} - onRefresh={crud.reload} - refreshing={crud.refreshing} /> {formDialog({ diff --git a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.test.tsx new file mode 100644 index 00000000000..66cc702df1e --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; +import type { ServiceTypeInstance } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; +import { resourcesApiRef } from '../../apis'; +import { ResourcesTabContent } from './ResourcesTabContent'; + +jest.mock('../../hooks/useTranslation', () => { + const mod = require('../../test-utils/mockTranslations'); + return { useTranslation: mod.mockUseTranslation }; +}); + +const MOCK_INSTANCE: ServiceTypeInstance = { + id: 'inst-1', + provider_name: 'my-provider', + status: 'active', + spec: { service_type: 'vm' }, +}; + +const baseResourcesApi = { + listServiceTypeInstances: jest.fn().mockResolvedValue({ + instances: [MOCK_INSTANCE], + next_page_token: undefined, + }), +}; + +function buildApi(overrides: Partial = {}) { + return { ...baseResourcesApi, ...overrides }; +} + +async function renderResourcesTab( + mockApi: ReturnType = buildApi(), +) { + return renderInTestApp( + + + , + ); +} + +describe('ResourcesTabContent', () => { + beforeEach(() => jest.clearAllMocks()); + + describe('initial load', () => { + it('calls listServiceTypeInstances with pagination params on mount', async () => { + const mockApi = buildApi(); + await renderResourcesTab(mockApi); + + await waitFor(() => + expect(mockApi.listServiceTypeInstances).toHaveBeenCalledTimes(1), + ); + expect(mockApi.listServiceTypeInstances).toHaveBeenCalledWith( + expect.objectContaining({ max_page_size: expect.any(Number) }), + ); + }); + + it('shows instance id in the table after successful load', async () => { + const mockApi = buildApi(); + await renderResourcesTab(mockApi); + + expect(await screen.findByText('inst-1')).toBeInTheDocument(); + }); + + it('shows the empty state when no instances are returned', async () => { + const mockApi = buildApi({ + listServiceTypeInstances: jest + .fn() + .mockResolvedValue({ instances: [] }), + }); + await renderResourcesTab(mockApi); + + expect( + await screen.findByText(/no resources found/i), + ).toBeInTheDocument(); + }); + }); + + describe('load error', () => { + it('shows an error alert when listServiceTypeInstances rejects', async () => { + const mockApi = buildApi({ + listServiceTypeInstances: jest + .fn() + .mockRejectedValue(new Error('Resources unavailable')), + }); + await renderResourcesTab(mockApi); + + expect( + await screen.findByText(/Resources unavailable/i), + ).toBeInTheDocument(); + }); + + it('shows a Retry button when the API rejects', async () => { + const mockApi = buildApi({ + listServiceTypeInstances: jest + .fn() + .mockRejectedValue(new Error('Resources unavailable')), + }); + await renderResourcesTab(mockApi); + + expect( + await screen.findByRole('button', { name: /retry/i }), + ).toBeInTheDocument(); + }); + + it('re-calls listServiceTypeInstances when Retry is clicked', async () => { + const listServiceTypeInstances = jest + .fn() + .mockRejectedValue(new Error('Resources unavailable')); + const mockApi = buildApi({ listServiceTypeInstances }); + await renderResourcesTab(mockApi); + + const retryBtn = await screen.findByRole('button', { name: /retry/i }); + fireEvent.click(retryBtn); + + await waitFor(() => + expect(listServiceTypeInstances).toHaveBeenCalledTimes(2), + ); + }); + }); + + describe('cursor pagination', () => { + it('shows Next button when next_page_token is returned', async () => { + const mockApi = buildApi({ + listServiceTypeInstances: jest.fn().mockResolvedValue({ + instances: [MOCK_INSTANCE], + next_page_token: 'tok-2', + }), + }); + await renderResourcesTab(mockApi); + + expect( + await screen.findByRole('button', { name: /next/i }), + ).toBeInTheDocument(); + }); + + it('Next button is disabled when no next_page_token', async () => { + const mockApi = buildApi({ + listServiceTypeInstances: jest.fn().mockResolvedValue({ + instances: [MOCK_INSTANCE], + next_page_token: '', + }), + }); + await renderResourcesTab(mockApi); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + expect(nextBtn).toBeDisabled(); + }); + + it('calls listServiceTypeInstances with next_page_token after clicking Next', async () => { + const listServiceTypeInstances = jest + .fn() + .mockResolvedValueOnce({ + instances: [MOCK_INSTANCE], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ + instances: [MOCK_INSTANCE], + next_page_token: '', + }); + const mockApi = buildApi({ listServiceTypeInstances }); + await renderResourcesTab(mockApi); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect(listServiceTypeInstances).toHaveBeenCalledWith( + expect.objectContaining({ page_token: 'tok-2' }), + ), + ); + }); + + it('Previous button is disabled on the first page', async () => { + const mockApi = buildApi({ + listServiceTypeInstances: jest.fn().mockResolvedValue({ + instances: [MOCK_INSTANCE], + next_page_token: 'tok-2', + }), + }); + await renderResourcesTab(mockApi); + + const prevBtn = await screen.findByRole('button', { name: /previous/i }); + expect(prevBtn).toBeDisabled(); + }); + + it('Previous button is enabled after navigating to page 2', async () => { + const listServiceTypeInstances = jest + .fn() + .mockResolvedValueOnce({ + instances: [MOCK_INSTANCE], + next_page_token: 'tok-2', + }) + .mockResolvedValueOnce({ + instances: [MOCK_INSTANCE], + next_page_token: '', + }); + const mockApi = buildApi({ listServiceTypeInstances }); + await renderResourcesTab(mockApi); + + const nextBtn = await screen.findByRole('button', { name: /next/i }); + fireEvent.click(nextBtn); + + await waitFor(() => + expect( + screen.getByRole('button', { name: /previous/i }), + ).not.toBeDisabled(), + ); + }); + }); +}); diff --git a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx index 2685af38b49..23543047dab 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx @@ -14,20 +14,20 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { TableColumn, Progress } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { Box, Button, Chip, Typography } from '@material-ui/core'; import MuiAlert from '@material-ui/lab/Alert'; import type { ServiceTypeInstance } from '@red-hat-developer-hub/backstage-plugin-dcm-common'; import { resourcesApiRef } from '../../apis'; -import { extractApiError } from '../../utils/extractApiError'; import { DcmSearchTableCard } from '../../components/dcmTabListHelpers'; import { useDcmStyles } from '../../components/dcmStyles'; import emptyIllustration from '../../assets/environments-empty-state.png'; import { DcmDataCenterTabEmptyState } from '../../components/DcmDataCenterTabEmptyState'; import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; import { usePersistedPageSize } from '../../hooks/usePersistedPageSize'; +import { usePaginatedFetch } from '../../hooks/usePaginatedFetch'; import { useTranslation } from '../../hooks/useTranslation'; export function ResourcesTabContent() { @@ -35,47 +35,40 @@ export function ResourcesTabContent() { const resourcesApi = useApi(resourcesApiRef); const { t } = useTranslation(); - const [instances, setInstances] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [search, setSearch] = useState(''); - const [page, setPage] = useState(0); const [pageSize, setPageSize] = usePersistedPageSize('resources'); + const [search, setSearch] = useState(''); - const load = useCallback(() => { - setLoading(true); - setLoadError(null); - resourcesApi - .listServiceTypeInstances() - .then(res => setInstances(res.instances ?? [])) - .catch(err => setLoadError(extractApiError(err))) - .finally(() => setLoading(false)); - }, [resourcesApi]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - setPage(0); - }, [search]); + const { + data, + loading, + error, + hasNext, + hasPrev, + goNext, + goPrev, + resetToFirstPage, + } = usePaginatedFetch({ + fetchFn: ({ pageToken, pageSize: ps }) => + resourcesApi + .listServiceTypeInstances({ page_token: pageToken, max_page_size: ps }) + .then(res => ({ + items: res.instances ?? [], + nextPageToken: res.next_page_token ?? '', + })), + pageSize, + }); const filtered = useMemo(() => { - if (!search.trim()) return instances; + if (!search.trim()) return data; const q = search.toLowerCase(); - return instances.filter( + return data.filter( inst => (inst.id ?? '').toLowerCase().includes(q) || (inst.spec?.service_type ?? '').toLowerCase().includes(q) || (inst.provider_name ?? '').toLowerCase().includes(q) || (inst.status ?? '').toLowerCase().includes(q), ); - }, [instances, search]); - - const paginated = useMemo(() => { - const start = page * pageSize; - return filtered.slice(start, start + pageSize); - }, [filtered, page, pageSize]); + }, [data, search]); const columns = useMemo[]>( () => [ @@ -145,21 +138,21 @@ export function ResourcesTabContent() { [classes, t], ); - if (loading) return ; + if (loading && data.length === 0) return ; - if (loadError) { + if (error) { return ( + } > - {loadError} + {error} ); @@ -167,7 +160,7 @@ export function ResourcesTabContent() { return ( - {instances.length === 0 ? ( + {data.length === 0 && !hasPrev ? ( title={(t as any)('resources.cardTitle', { count: filtered.length })} - data={paginated} + data={filtered} columns={columns} totalCount={filtered.length} - page={page} + page={1} pageSize={pageSize} - setPage={setPage} + setPage={() => {}} setPageSize={setPageSize} search={search} setSearch={setSearch} + cursorPagination={{ + hasNext, + hasPrev, + onNext: goNext, + onPrev: goPrev, + loading, + pageSize, + onPageSizeChange: setPageSize, + }} /> )} diff --git a/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.test.tsx index 7d7316b35ae..d8d42205353 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.test.tsx @@ -69,9 +69,9 @@ describe('ServiceTypesTabContent', () => { }; renderWith(mockApi); - await waitFor(() => - expect(screen.getByText(/Service unavailable/i)).toBeInTheDocument(), - ); + expect( + await screen.findByText(/Service unavailable/i), + ).toBeInTheDocument(); }); it('shows a Retry button when the API rejects', async () => { @@ -82,11 +82,9 @@ describe('ServiceTypesTabContent', () => { }; renderWith(mockApi); - await waitFor(() => - expect( - screen.getByRole('button', { name: /retry/i }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole('button', { name: /retry/i }), + ).toBeInTheDocument(); }); it('re-calls listServiceTypes when Retry is clicked', async () => { @@ -146,11 +144,9 @@ describe('ServiceTypesTabContent', () => { }; renderWith(mockApi); - await waitFor(() => - expect( - screen.getByText(/no service types defined/i), - ).toBeInTheDocument(), - ); + expect( + await screen.findByText(/no service types defined/i), + ).toBeInTheDocument(); }); it('does not show an error alert when the list is empty', async () => { @@ -159,11 +155,9 @@ describe('ServiceTypesTabContent', () => { }; renderWith(mockApi); - await waitFor(() => - expect( - screen.getByText(/no service types defined/i), - ).toBeInTheDocument(), - ); + expect( + await screen.findByText(/no service types defined/i), + ).toBeInTheDocument(); expect( screen.queryByRole('button', { name: /retry/i }), ).not.toBeInTheDocument(); diff --git a/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.tsx index da74a763549..b7fd7aa632f 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/service-types/ServiceTypesTabContent.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { usePersistedPageSize } from '../../hooks/usePersistedPageSize'; +import { usePaginatedFetch } from '../../hooks/usePaginatedFetch'; import { TableColumn, Progress } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { Box, Button, Chip, Typography } from '@material-ui/core'; @@ -24,7 +25,6 @@ import type { ServiceType } from '@red-hat-developer-hub/backstage-plugin-dcm-co import { catalogApiRef } from '../../apis'; import { DcmSearchTableCard } from '../../components/dcmTabListHelpers'; import { useDcmStyles } from '../../components/dcmStyles'; -import { extractApiError } from '../../utils/extractApiError'; import emptyIllustration from '../../assets/environments-empty-state.png'; import { DcmDataCenterTabEmptyState } from '../../components/DcmDataCenterTabEmptyState'; import { DcmEmptyCell, TruncatedText } from '../../components/TruncatedText'; @@ -37,49 +37,39 @@ export function ServiceTypesTabContent() { const catalogApi = useApi(catalogApiRef); const { t } = useTranslation(); - const [serviceTypes, setServiceTypes] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [search, setSearch] = useState(''); - const [page, setPage] = useState(0); const [pageSize, setPageSize] = usePersistedPageSize('service-types'); + const [search, setSearch] = useState(''); - const load = useCallback(() => { - setLoading(true); - setLoadError(null); - catalogApi - .listServiceTypes() - .then(res => setServiceTypes(res.results ?? [])) - .catch(err => { - setLoadError(extractApiError(err)); - setServiceTypes([]); - }) - .finally(() => setLoading(false)); - }, [catalogApi]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - setPage(0); - }, [search]); + const { + data, + loading, + error, + hasNext, + hasPrev, + goNext, + goPrev, + resetToFirstPage, + } = usePaginatedFetch({ + fetchFn: ({ pageToken, pageSize: ps }) => + catalogApi + .listServiceTypes({ page_token: pageToken, max_page_size: ps }) + .then(res => ({ + items: res.results ?? [], + nextPageToken: res.next_page_token ?? '', + })), + pageSize, + }); const filtered = useMemo(() => { - if (!search.trim()) return serviceTypes; + if (!search.trim()) return data; const q = search.toLowerCase(); - return serviceTypes.filter( + return data.filter( st => (st.service_type ?? '').toLowerCase().includes(q) || (st.api_version ?? '').toLowerCase().includes(q) || (st.uid ?? '').toLowerCase().includes(q), ); - }, [serviceTypes, search]); - - const paginated = useMemo(() => { - const start = page * pageSize; - return filtered.slice(start, start + pageSize); - }, [filtered, page, pageSize]); + }, [data, search]); const columns = useMemo[]>( () => [ @@ -151,21 +141,21 @@ export function ServiceTypesTabContent() { [classes, t], ); - if (loading) return ; + if (loading && data.length === 0) return ; - if (loadError) { + if (error) { return ( + } > - {loadError} + {error} ); @@ -173,7 +163,7 @@ export function ServiceTypesTabContent() { return ( - {serviceTypes.length === 0 ? ( + {data.length === 0 && !hasPrev ? ( {}} setPageSize={setPageSize} search={search} setSearch={setSearch} + cursorPagination={{ + hasNext, + hasPrev, + onNext: goNext, + onPrev: goPrev, + loading, + pageSize, + onPageSizeChange: setPageSize, + }} /> )} diff --git a/workspaces/dcm/plugins/dcm/src/translations/de.ts b/workspaces/dcm/plugins/dcm/src/translations/de.ts index f4685932e18..fe48c0b33de 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/de.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/de.ts @@ -45,6 +45,8 @@ const dcmTranslationDe: TranslationMessages< 'common.saving': 'Wird gespeichert\u2026', 'common.close': 'Schlie\u00dfen', 'common.rows': 'Zeilen', + 'common.previousPage': 'Zurück', + 'common.nextPage': 'Weiter', 'deleteDialog.title': '{{resourceLabel}} l\u00f6schen', 'deleteDialog.confirmButton': 'L\u00f6schen', 'deleteDialog.cancelButton': 'Abbrechen', diff --git a/workspaces/dcm/plugins/dcm/src/translations/es.ts b/workspaces/dcm/plugins/dcm/src/translations/es.ts index 8ea3d7b4dce..1145b6d3d1b 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/es.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/es.ts @@ -45,6 +45,8 @@ const dcmTranslationEs: TranslationMessages< 'common.saving': 'Guardando\u2026', 'common.close': 'Cerrar', 'common.rows': 'filas', + 'common.previousPage': 'Anterior', + 'common.nextPage': 'Siguiente', 'deleteDialog.title': 'Eliminar {{resourceLabel}}', 'deleteDialog.confirmButton': 'Eliminar', 'deleteDialog.cancelButton': 'Cancelar', diff --git a/workspaces/dcm/plugins/dcm/src/translations/fr.ts b/workspaces/dcm/plugins/dcm/src/translations/fr.ts index f3e4a887ef7..afdaf907a69 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/fr.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/fr.ts @@ -45,6 +45,8 @@ const dcmTranslationFr: TranslationMessages< 'common.saving': 'Enregistrement\u2026', 'common.close': 'Fermer', 'common.rows': 'lignes', + 'common.previousPage': 'Précédent', + 'common.nextPage': 'Suivant', 'deleteDialog.title': 'Supprimer {{resourceLabel}}', 'deleteDialog.confirmButton': 'Supprimer', 'deleteDialog.cancelButton': 'Annuler', diff --git a/workspaces/dcm/plugins/dcm/src/translations/it.ts b/workspaces/dcm/plugins/dcm/src/translations/it.ts index 8fe98c51b4d..bf576f0189b 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/it.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/it.ts @@ -45,6 +45,8 @@ const dcmTranslationIt: TranslationMessages< 'common.saving': 'Salvataggio\u2026', 'common.close': 'Chiudi', 'common.rows': 'righe', + 'common.previousPage': 'Precedente', + 'common.nextPage': 'Successivo', 'deleteDialog.title': 'Elimina {{resourceLabel}}', 'deleteDialog.confirmButton': 'Elimina', 'deleteDialog.cancelButton': 'Annulla', diff --git a/workspaces/dcm/plugins/dcm/src/translations/ja.ts b/workspaces/dcm/plugins/dcm/src/translations/ja.ts index 2a9eac52a55..5e5243d956d 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/ja.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/ja.ts @@ -48,6 +48,8 @@ const dcmTranslationJa: TranslationMessages< 'common.saving': '\u4fdd\u5b58\u4e2d\u2026', 'common.close': '\u9589\u3058\u308b', 'common.rows': '\u884c', + 'common.previousPage': '\u524d\u3078', + 'common.nextPage': '\u6b21\u3078', 'deleteDialog.title': '{{resourceLabel}}\u3092\u524a\u9664', 'deleteDialog.confirmButton': '\u524a\u9664', 'deleteDialog.cancelButton': '\u30ad\u30e3\u30f3\u30bb\u30eb', diff --git a/workspaces/dcm/plugins/dcm/src/translations/ref.ts b/workspaces/dcm/plugins/dcm/src/translations/ref.ts index e56e048dca6..eabd1d70296 100644 --- a/workspaces/dcm/plugins/dcm/src/translations/ref.ts +++ b/workspaces/dcm/plugins/dcm/src/translations/ref.ts @@ -49,6 +49,8 @@ export const dcmMessages = { saving: 'Saving\u2026', close: 'Close', rows: 'rows', + previousPage: 'Previous', + nextPage: 'Next', }, deleteDialog: { title: 'Delete {{resourceLabel}}',