From d61c0d0d9d32a5421dfc691dc89ba7568511f4f5 Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Tue, 4 Aug 2026 10:51:31 +0300 Subject: [PATCH 1/5] Add Server Side Paginations --- .../server-side-pagination-all-tabs.md | 28 ++ .../dcm/plugins/dcm-common/report.api.md | 39 ++- .../dcm-common/src/clients/CatalogApi.ts | 9 +- .../dcm-common/src/clients/CatalogClient.ts | 26 +- .../src/clients/PolicyManagerApi.ts | 3 +- .../src/clients/PolicyManagerClient.ts | 6 +- .../dcm-common/src/clients/ProvidersApi.ts | 3 +- .../dcm-common/src/clients/ProvidersClient.ts | 6 +- .../dcm/plugins/dcm-common/src/index.ts | 1 + .../plugins/dcm-common/src/types/catalog.ts | 6 +- .../plugins/dcm-common/src/types/common.ts | 13 + .../src/utils/buildPaginationQuery.ts | 32 ++ .../components/CursorPaginationControls.tsx | 150 +++++++++ .../dcm/src/components/DcmCrudTabLayout.tsx | 86 +++-- .../dcm/src/components/dcmTabListHelpers.tsx | 83 +++-- .../plugins/dcm/src/hooks/useCrudTab.test.ts | 45 +++ .../dcm/plugins/dcm/src/hooks/useCrudTab.ts | 31 +- .../dcm/src/hooks/usePaginatedCrudTab.ts | 211 +++++++++++++ .../dcm/src/hooks/usePaginatedFetch.test.ts | 210 +++++++++++++ .../dcm/src/hooks/usePaginatedFetch.ts | 199 ++++++++++++ .../CatalogItemInstancesTabContent.tsx | 35 ++- .../catalog-items/CatalogItemsTabContent.tsx | 31 +- .../components/CatalogItemFormFields.test.tsx | 12 +- .../policies/PoliciesTabContent.test.tsx | 86 ++++- .../src/pages/policies/PoliciesTabContent.tsx | 26 +- .../providers/ProvidersTabContent.test.tsx | 293 ++++++++++++++++++ .../pages/providers/ProvidersTabContent.tsx | 37 ++- .../resources/ResourcesTabContent.test.tsx | 225 ++++++++++++++ .../pages/resources/ResourcesTabContent.tsx | 86 ++--- .../ServiceTypesTabContent.test.tsx | 30 +- .../service-types/ServiceTypesTabContent.tsx | 89 +++--- .../dcm/plugins/dcm/src/translations/de.ts | 2 + .../dcm/plugins/dcm/src/translations/es.ts | 2 + .../dcm/plugins/dcm/src/translations/fr.ts | 2 + .../dcm/plugins/dcm/src/translations/it.ts | 2 + .../dcm/plugins/dcm/src/translations/ja.ts | 2 + .../dcm/plugins/dcm/src/translations/ref.ts | 2 + 37 files changed, 1901 insertions(+), 248 deletions(-) create mode 100644 workspaces/dcm/.changeset/server-side-pagination-all-tabs.md create mode 100644 workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts create mode 100644 workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts create mode 100644 workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts create mode 100644 workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts create mode 100644 workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.test.tsx 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..a799ac7406a --- /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 the `useCrudTab` + manual token-stack pattern (same as Catalog Items). Next / Previous buttons appear below the table; search resets to page 1 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 calls capped at 25 items** + +Service-type dropdown loads (used in the Providers and Catalog Items create/edit forms) and the catalog-item dropdown load (used in the Instances create form) now pass `max_page_size: 25`. This prevents the select from silently showing an incomplete list when there are more than the default page size of items. + +**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.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.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..bd0d2187653 --- /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, 15, 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..4275711bfca 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, @@ -237,28 +255,38 @@ 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..1ff6cfd17c1 100644 --- a/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx @@ -30,6 +30,7 @@ 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 } from './CursorPaginationControls'; const useStyles = makeStyles({ filterInput: { minWidth: 200 }, @@ -107,12 +108,26 @@ function ActionsCell({ onEdit, onDelete }: ActionsCellProps) { ); } +export type CursorPaginationProps = Readonly<{ + hasNext: boolean; + hasPrev: boolean; + onNext: () => void; + onPrev: () => void; + 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[]; +}>; + 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 +136,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 +160,7 @@ export function DcmSearchTableCard({ search, setSearch, pageSizeOptions = [5, 10, 25], + cursorPagination, }: DcmSearchTableCardProps) { const classes = useDcmStyles(); const { t } = useTranslation(); @@ -157,31 +178,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.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts new file mode 100644 index 00000000000..0549c9e5e31 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts @@ -0,0 +1,211 @@ +/* + * 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'; + +/** 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; +} + +/** Props that can be passed directly to {@link DcmCrudTabLayout}'s `cursorPagination`. */ +export interface CursorPaginationProps { + hasNext: boolean; + hasPrev: boolean; + onNext: () => void; + onPrev: () => void; + loading: boolean; + /** Currently selected page size. */ + pageSize: number; + /** Called with the newly selected page size; resets cursor to page 1 and reloads. */ + onPageSizeChange: (size: number) => void; +} + +/** + * 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` that also resets cursor navigation + * to page 1 without triggering an extra API call. + */ + 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 { + const [pageSize, setPageSize] = usePersistedPageSize(options.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({ + ...options, + 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], + ); + + // When the search changes, reset cursor navigation state (no API call + // needed — search filters the current page client-side). + const handleSearchChange = useCallback( + (value: React.SetStateAction) => { + currentTokenRef.current = undefined; + tokenStackRef.current = []; + setTokenStack([]); + 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..5ea7e238b86 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts @@ -0,0 +1,210 @@ +/* + * 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); + }); + }); +}); 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..9d9d7c881f3 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts @@ -0,0 +1,199 @@ +/* + * 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; + /** + * Resets only the navigation state (token stack + next token) without + * triggering a new API request. Use when client-side filtering changes so + * the cursor controls reflect "page 1" without an extra round-trip. + */ + resetCursor: () => 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]); + + const resetCursor = useCallback(() => { + currentTokenRef.current = undefined; + tokenStackRef.current = []; + setTokenStack([]); + setNextToken(''); + }, []); + + return { + data, + loading, + error, + hasNext: Boolean(nextToken), + hasPrev: tokenStack.length > 0, + goNext, + goPrev, + refresh, + resetToFirstPage, + resetCursor, + }; +} 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..d9cd78e8203 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 @@ -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'; @@ -87,15 +87,26 @@ export function CatalogItemInstancesTabContent() { 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 ?? []), + const crud = usePaginatedCrudTab({ + loadFn: async ({ pageToken, pageSize: ps }) => { + const [instanceResult, itemList] = await Promise.all([ + catalogApi + .listCatalogItemInstances({ + page_token: pageToken, + max_page_size: ps, + }) + .then(r => ({ + items: r.results ?? [], + nextPageToken: r.next_page_token, + })), + catalogApi + .listCatalogItems({ max_page_size: 25 }) + .then(r => r.results ?? []), ]); setCatalogItems(itemList); - return instanceList; + return instanceResult; }, + storageKey: 'catalog-item-instances', createFn: form => catalogApi.createCatalogItemInstance(formToInstance(form)), deleteFn: id => catalogApi.deleteCatalogItemInstance(id), @@ -108,7 +119,6 @@ export function CatalogItemInstancesTabContent() { ], emptyForm: emptyInstanceForm, isValid: isInstanceFormValid, - storageKey: 'catalog-item-instances', }); const { handleOpenDelete, setItems } = crud; @@ -276,7 +286,7 @@ export function CatalogItemInstancesTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} @@ -284,11 +294,8 @@ export function CatalogItemInstancesTabContent() { actionError={rehydrateError} onDismissActionError={() => 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..0e9303be4ab 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/catalog-items/CatalogItemsTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/catalog-items/CatalogItemsTabContent.tsx @@ -80,7 +80,7 @@ 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'; @@ -103,15 +103,26 @@ export function CatalogItemsTabContent() { const [createSubmitAttempted, setCreateSubmitAttempted] = useState(false); const [editSubmitAttempted, setEditSubmitAttempted] = useState(false); - const crud = useCrudTab({ - loadFn: async () => { + const crud = usePaginatedCrudTab({ + loadFn: async ({ pageToken, pageSize: ps }) => { const [itemList, serviceTypeList] = await Promise.all([ - catalogApi.listCatalogItems().then(r => r.results ?? []), - catalogApi.listServiceTypes().then(r => r.results ?? []), + catalogApi + .listCatalogItems({ + page_token: pageToken, + max_page_size: ps, + }) + .then(r => ({ + items: r.results ?? [], + nextPageToken: r.next_page_token, + })), + catalogApi + .listServiceTypes({ max_page_size: 25 }) + .then(r => r.results ?? []), ]); setServiceTypes(serviceTypeList); return itemList; }, + storageKey: 'catalog-items', createFn: form => catalogApi.createCatalogItem(formToCatalogItem(form)), updateFn: (id, form) => catalogApi.updateCatalogItem(id, formToCatalogItemForUpdate(form)), @@ -125,7 +136,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,7 +331,7 @@ export function CatalogItemsTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} @@ -329,11 +339,8 @@ export function CatalogItemsTabContent() { actionError={null} onDismissActionError={undefined} 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..73980da4446 --- /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: 25 for the dropdown', async () => { + const apis = buildApis(); + await renderProvidersTab(apis); + + await waitFor(() => + expect(apis.catalog.listServiceTypes).toHaveBeenCalledTimes(1), + ); + expect(apis.catalog.listServiceTypes).toHaveBeenCalledWith({ + max_page_size: 25, + }); + }); + + 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 "15" from the opened dropdown menu. + const option15 = await screen.findByRole('option', { name: '15' }); + fireEvent.click(option15); + + await waitFor(() => + expect(listProviders).toHaveBeenCalledWith( + expect.objectContaining({ max_page_size: 15 }), + ), + ); + }); + + 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..ca02f0c0827 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx @@ -49,7 +49,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'; @@ -72,15 +72,26 @@ export function ProvidersTabContent() { const [serviceTypes, setServiceTypes] = useState([]); - const crud = useCrudTab({ - loadFn: async () => { - const [providerList, serviceTypeList] = await Promise.all([ - providersApi.listProviders().then(r => r.providers ?? []), - catalogApi.listServiceTypes().then(r => r.results ?? []), + const crud = usePaginatedCrudTab({ + loadFn: async ({ pageToken, pageSize: ps }) => { + const [providerResult, serviceTypeList] = await Promise.all([ + providersApi + .listProviders({ + page_token: pageToken, + max_page_size: ps, + }) + .then(r => ({ + items: r.providers ?? [], + nextPageToken: r.next_page_token, + })), + catalogApi + .listServiceTypes({ max_page_size: 25 }) + .then(r => r.results ?? []), ]); setServiceTypes(serviceTypeList); - return providerList; + return providerResult; }, + storageKey: 'providers', createFn: form => providersApi.createProvider(formToProvider(form)), updateFn: (id, form) => providersApi.applyProvider(id, formToProvider(form)), @@ -90,7 +101,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,7 +308,7 @@ export function ProvidersTabContent() { items={crud.items} filtered={crud.filtered} - paginated={crud.paginated} + paginated={crud.filtered} columns={columns} loading={crud.loading} loadError={crud.loadError} @@ -306,19 +316,14 @@ export function ProvidersTabContent() { actionError={null} onDismissActionError={undefined} 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..bcb817a468a 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 { useEffect, 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,48 @@ 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]); + const { + data, + loading, + error, + hasNext, + hasPrev, + goNext, + goPrev, + resetToFirstPage, + resetCursor, + } = 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, + }); + // When the search query changes, reset cursor navigation state so that + // the Previous/Next controls reflect "page 1". No API call is needed + // because search filtering happens client-side on the current page. useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - setPage(0); - }, [search]); + resetCursor(); + }, [search, resetCursor]); 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 +146,21 @@ export function ResourcesTabContent() { [classes, t], ); - if (loading) return ; + if (loading && data.length === 0) return ; - if (loadError) { + if (error) { return ( + } > - {loadError} + {error} ); @@ -167,7 +168,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..615b298a809 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 { useEffect, 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,47 @@ 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]); + const { + data, + loading, + error, + hasNext, + hasPrev, + goNext, + goPrev, + resetToFirstPage, + resetCursor, + } = 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, + }); + // When the search query changes, reset cursor navigation state so that + // the Previous/Next controls reflect "page 1". No API call is needed + // because search filtering happens client-side on the current page. useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - setPage(0); - }, [search]); + resetCursor(); + }, [search, resetCursor]); 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 +149,21 @@ export function ServiceTypesTabContent() { [classes, t], ); - if (loading) return ; + if (loading && data.length === 0) return ; - if (loadError) { + if (error) { return ( + } > - {loadError} + {error} ); @@ -173,7 +171,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}}', From 11a44cf804b6b4ac6992785602b70b4bbce76294 Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Tue, 4 Aug 2026 15:54:23 +0300 Subject: [PATCH 2/5] Address Feedback --- .../server-side-pagination-all-tabs.md | 6 +- .../src/clients/ProvidersClient.test.ts | 13 +++++ .../src/utils/buildPaginationQuery.test.ts | 55 +++++++++++++++++++ .../components/CursorPaginationControls.tsx | 10 ++-- .../dcm/src/components/DcmCrudTabLayout.tsx | 6 +- .../dcm/src/components/dcmTabListHelpers.tsx | 20 ++----- .../dcm/src/hooks/usePaginatedCrudTab.ts | 34 +++++------- .../dcm/src/hooks/usePaginatedFetch.test.ts | 46 ++++++++++++++++ .../dcm/src/hooks/usePaginatedFetch.ts | 14 ----- .../catalog-items/CatalogItemsTabContent.tsx | 35 ++++++------ .../providers/ProvidersTabContent.test.tsx | 12 ++-- .../pages/providers/ProvidersTabContent.tsx | 35 ++++++------ .../pages/resources/ResourcesTabContent.tsx | 10 +--- .../service-types/ServiceTypesTabContent.tsx | 10 +--- 14 files changed, 186 insertions(+), 120 deletions(-) create mode 100644 workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts diff --git a/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md b/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md index a799ac7406a..e206f329fb2 100644 --- a/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md +++ b/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md @@ -9,7 +9,7 @@ Add server-side cursor pagination to all tabs and harden pagination handling acr 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 the `useCrudTab` + manual token-stack pattern (same as Catalog Items). Next / Previous buttons appear below the table; search resets to page 1 client-side without an extra round-trip. +- **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** @@ -19,9 +19,9 @@ Previously only Service Types, Catalog Items, and Catalog Item Instances fetched - `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 calls capped at 25 items** +**Dropdown options loaded once on mount** -Service-type dropdown loads (used in the Providers and Catalog Items create/edit forms) and the catalog-item dropdown load (used in the Instances create form) now pass `max_page_size: 25`. This prevents the select from silently showing an incomplete list when there are more than the default page size of items. +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** 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/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/src/components/CursorPaginationControls.tsx b/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx index bd0d2187653..73abc0cd222 100644 --- a/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx @@ -63,7 +63,7 @@ export function CursorPaginationControls({ loading = false, pageSize, onPageSizeChange, - pageSizeOptions = [5, 15, 25], + pageSizeOptions = [5, 10, 25], }: CursorPaginationControlsProps) { const classes = useStyles(); const { t } = useTranslation(); @@ -87,7 +87,7 @@ export function CursorPaginationControls({ )} - + - + - + - + ); diff --git a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx index 4275711bfca..1e2f849dd4f 100644 --- a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx @@ -187,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 && ( diff --git a/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx b/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx index 1ff6cfd17c1..243db528d78 100644 --- a/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/dcmTabListHelpers.tsx @@ -30,7 +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 } from './CursorPaginationControls'; +import { + CursorPaginatedTable, + type CursorPaginationControlsProps, +} from './CursorPaginationControls'; const useStyles = makeStyles({ filterInput: { minWidth: 200 }, @@ -108,19 +111,8 @@ function ActionsCell({ onEdit, onDelete }: ActionsCellProps) { ); } -export type CursorPaginationProps = Readonly<{ - hasNext: boolean; - hasPrev: boolean; - onNext: () => void; - onPrev: () => void; - 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[]; -}>; +/** Alias kept for backwards compatibility — use {@link CursorPaginationControlsProps} directly when possible. */ +export type CursorPaginationProps = CursorPaginationControlsProps; export type DcmSearchTableCardProps = Readonly<{ title: string; diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts index 0549c9e5e31..1d44ceb62eb 100644 --- a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedCrudTab.ts @@ -22,6 +22,7 @@ import { 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 { @@ -55,18 +56,8 @@ export interface UsePaginatedCrudTabOptions< storageKey: string; } -/** Props that can be passed directly to {@link DcmCrudTabLayout}'s `cursorPagination`. */ -export interface CursorPaginationProps { - hasNext: boolean; - hasPrev: boolean; - onNext: () => void; - onPrev: () => void; - loading: boolean; - /** Currently selected page size. */ - pageSize: number; - /** Called with the newly selected page size; resets cursor to page 1 and reloads. */ - onPageSizeChange: (size: number) => void; -} +/** Alias kept for backwards compatibility — use {@link CursorPaginationControlsProps} directly when possible. */ +export type CursorPaginationProps = CursorPaginationControlsProps; /** * Result returned by {@link usePaginatedCrudTab}. @@ -79,8 +70,8 @@ export interface UsePaginatedCrudTabResult> goNext: () => void; goPrev: () => void; /** - * Drop-in replacement for `crud.setSearch` that also resets cursor navigation - * to page 1 without triggering an extra API call. + * 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; /** @@ -119,7 +110,11 @@ export interface UsePaginatedCrudTabResult> export function usePaginatedCrudTab>( options: UsePaginatedCrudTabOptions, ): UsePaginatedCrudTabResult { - const [pageSize, setPageSize] = usePersistedPageSize(options.storageKey); + // 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; @@ -136,7 +131,7 @@ export function usePaginatedCrudTab>( optsRef.current = options; const crud = useCrudTab({ - ...options, + ...crudOptions, loadFn: () => optsRef.current.loadFn({ pageToken: currentTokenRef.current, @@ -180,13 +175,10 @@ export function usePaginatedCrudTab>( [setPageSize, crudReload], ); - // When the search changes, reset cursor navigation state (no API call - // needed — search filters the current page client-side). + // 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) => { - currentTokenRef.current = undefined; - tokenStackRef.current = []; - setTokenStack([]); setCrudSearch(value); }, [setCrudSearch], diff --git a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts index 5ea7e238b86..ebc749572f3 100644 --- a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.test.ts @@ -207,4 +207,50 @@ describe('usePaginatedFetch', () => { 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 index 9d9d7c881f3..338430a0e54 100644 --- a/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts +++ b/workspaces/dcm/plugins/dcm/src/hooks/usePaginatedFetch.ts @@ -51,12 +51,6 @@ export interface UsePaginatedFetchResult { refresh: () => void; /** Resets cursor to the first page and re-fetches. */ resetToFirstPage: () => void; - /** - * Resets only the navigation state (token stack + next token) without - * triggering a new API request. Use when client-side filtering changes so - * the cursor controls reflect "page 1" without an extra round-trip. - */ - resetCursor: () => void; } /** @@ -177,13 +171,6 @@ export function usePaginatedFetch( fetch(); }, [fetch]); - const resetCursor = useCallback(() => { - currentTokenRef.current = undefined; - tokenStackRef.current = []; - setTokenStack([]); - setNextToken(''); - }, []); - return { data, loading, @@ -194,6 +181,5 @@ export function usePaginatedFetch( goPrev, refresh, resetToFirstPage, - resetCursor, }; } 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 0e9303be4ab..bc6f925c4a0 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 { @@ -103,25 +103,22 @@ export function CatalogItemsTabContent() { const [createSubmitAttempted, setCreateSubmitAttempted] = useState(false); const [editSubmitAttempted, setEditSubmitAttempted] = useState(false); + // 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(() => {}); + }, [catalogApi]); + const crud = usePaginatedCrudTab({ - loadFn: async ({ pageToken, pageSize: ps }) => { - const [itemList, serviceTypeList] = await Promise.all([ - catalogApi - .listCatalogItems({ - page_token: pageToken, - max_page_size: ps, - }) - .then(r => ({ - items: r.results ?? [], - nextPageToken: r.next_page_token, - })), - catalogApi - .listServiceTypes({ max_page_size: 25 }) - .then(r => r.results ?? []), - ]); - setServiceTypes(serviceTypeList); - return itemList; - }, + 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) => diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx index 73980da4446..1439da39248 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.test.tsx @@ -114,7 +114,7 @@ describe('ProvidersTabContent', () => { ); }); - it('calls listServiceTypes with max_page_size: 25 for the dropdown', async () => { + it('calls listServiceTypes with max_page_size: 100 for the dropdown (once on mount)', async () => { const apis = buildApis(); await renderProvidersTab(apis); @@ -122,7 +122,7 @@ describe('ProvidersTabContent', () => { expect(apis.catalog.listServiceTypes).toHaveBeenCalledTimes(1), ); expect(apis.catalog.listServiceTypes).toHaveBeenCalledWith({ - max_page_size: 25, + max_page_size: 100, }); }); @@ -255,13 +255,13 @@ describe('ProvidersTabContent', () => { // The trigger renders as a button inside the pagination controls. fireEvent.mouseDown(screen.getByRole('button', { name: /rows/i })); - // Pick "15" from the opened dropdown menu. - const option15 = await screen.findByRole('option', { name: '15' }); - fireEvent.click(option15); + // 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: 15 }), + expect.objectContaining({ max_page_size: 10 }), ), ); }); diff --git a/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/providers/ProvidersTabContent.tsx index ca02f0c0827..2bbf82ed980 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'; @@ -72,25 +72,22 @@ export function ProvidersTabContent() { const [serviceTypes, setServiceTypes] = useState([]); + // 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(() => {}); + }, [catalogApi]); + const crud = usePaginatedCrudTab({ - loadFn: async ({ pageToken, pageSize: ps }) => { - const [providerResult, serviceTypeList] = await Promise.all([ - providersApi - .listProviders({ - page_token: pageToken, - max_page_size: ps, - }) - .then(r => ({ - items: r.providers ?? [], - nextPageToken: r.next_page_token, - })), - catalogApi - .listServiceTypes({ max_page_size: 25 }) - .then(r => r.results ?? []), - ]); - setServiceTypes(serviceTypeList); - return providerResult; - }, + 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) => diff --git a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx index bcb817a468a..23543047dab 100644 --- a/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx +++ b/workspaces/dcm/plugins/dcm/src/pages/resources/ResourcesTabContent.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { 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'; @@ -47,7 +47,6 @@ export function ResourcesTabContent() { goNext, goPrev, resetToFirstPage, - resetCursor, } = usePaginatedFetch({ fetchFn: ({ pageToken, pageSize: ps }) => resourcesApi @@ -59,13 +58,6 @@ export function ResourcesTabContent() { pageSize, }); - // When the search query changes, reset cursor navigation state so that - // the Previous/Next controls reflect "page 1". No API call is needed - // because search filtering happens client-side on the current page. - useEffect(() => { - resetCursor(); - }, [search, resetCursor]); - const filtered = useMemo(() => { if (!search.trim()) return data; const q = search.toLowerCase(); 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 615b298a809..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,7 +14,7 @@ * limitations under the License. */ -import { 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'; @@ -49,7 +49,6 @@ export function ServiceTypesTabContent() { goNext, goPrev, resetToFirstPage, - resetCursor, } = usePaginatedFetch({ fetchFn: ({ pageToken, pageSize: ps }) => catalogApi @@ -61,13 +60,6 @@ export function ServiceTypesTabContent() { pageSize, }); - // When the search query changes, reset cursor navigation state so that - // the Previous/Next controls reflect "page 1". No API call is needed - // because search filtering happens client-side on the current page. - useEffect(() => { - resetCursor(); - }, [search, resetCursor]); - const filtered = useMemo(() => { if (!search.trim()) return data; const q = search.toLowerCase(); From aaada6ea85aa5d0a0a3e1c9eb6a9fe2dd747206a Mon Sep 17 00:00:00 2001 From: Ashraf Masarwa Date: Wed, 5 Aug 2026 12:35:52 +0300 Subject: [PATCH 3/5] Implement Multi-Service Types --- .../multi-resource-catalog-items.md | 21 + .../dcm/plugins/dcm-common/report.api.md | 14 +- .../src/clients/CatalogClient.test.ts | 2 +- .../plugins/dcm-common/src/types/catalog.ts | 25 +- .../dcm/src/components/SchemaButton.tsx | 279 +++++++ .../dcm/src/components/UserValueFields.tsx | 164 +++++ .../dcm/src/components/VerticalTabDialog.tsx | 293 ++++++++ .../CatalogItemInstancesTabContent.tsx | 73 +- .../components/InstanceFormFields.tsx | 320 +------- .../components/InstanceWizardDialog.tsx | 359 +++++++++ .../instanceFormTypes.test.ts | 82 ++- .../instanceFormTypes.ts | 168 +++-- .../catalog-items/CatalogItemsTabContent.tsx | 295 ++------ .../catalog-items/catalogItemFormTypes.ts | 340 ++++++--- .../components/CatalogItemFormFields.test.tsx | 59 +- .../components/CatalogItemFormFields.tsx | 663 +---------------- .../components/CatalogItemWizardDialog.tsx | 682 ++++++++++++++++++ .../components/ResourceFieldsPanel.tsx | 229 ++++++ .../dcm/plugins/dcm/src/translations/ref.ts | 44 +- .../dcm/src/utils/validateJsonObject.ts | 44 ++ 20 files changed, 2674 insertions(+), 1482 deletions(-) create mode 100644 workspaces/dcm/.changeset/multi-resource-catalog-items.md create mode 100644 workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/components/UserValueFields.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/components/VerticalTabDialog.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/catalog-item-instances/components/InstanceWizardDialog.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/catalog-items/components/CatalogItemWizardDialog.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/pages/catalog-items/components/ResourceFieldsPanel.tsx create mode 100644 workspaces/dcm/plugins/dcm/src/utils/validateJsonObject.ts diff --git a/workspaces/dcm/.changeset/multi-resource-catalog-items.md b/workspaces/dcm/.changeset/multi-resource-catalog-items.md new file mode 100644 index 00000000000..e24d9ecedf0 --- /dev/null +++ b/workspaces/dcm/.changeset/multi-resource-catalog-items.md @@ -0,0 +1,21 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dcm-common': minor +'@red-hat-developer-hub/backstage-plugin-dcm': minor +--- + +Add multi-resource support for Catalog Items and Catalog Item Instances. + +**API type changes (`dcm-common`)** + +- `CatalogItemSpec` now holds a `resources?: CatalogResource[]` array instead of a single `service_type` + `fields`. +- New `CatalogResource` interface: `{ name, service_type, requires_resources?, fields? }`. +- `UserValue` gains a required `resource` field that identifies which resource the value targets. +- `CatalogItemInstanceSpec` gains `resource_ids?: string[]` (replaces the top-level `resource_id`). + +**UI changes (`dcm`)** + +- Catalog Item create/edit now uses a vertical-tabbed wizard dialog (`CatalogItemWizardDialog`) with tabs: Overview, API, Resources, and one tab per resource for field configurations. +- Catalog Item Instance create now uses a vertical-tabbed wizard dialog (`InstanceWizardDialog`) with an Overview tab and one tab per resource that has editable fields. +- Shared components extracted: `VerticalTabDialog`, `SchemaButton`, `ResourceFieldsPanel`, `UserValueFields`. +- Shared utility `validateJsonObject` de-duplicates JSON-object validation across `SchemaButton` and `catalogItemFormTypes`. +- Table columns updated: "Service type" replaced by "Resources" (chips per service_type); field count sums across all resources. diff --git a/workspaces/dcm/plugins/dcm-common/report.api.md b/workspaces/dcm/plugins/dcm-common/report.api.md index 6106ad3eeb5..1335c49da0e 100644 --- a/workspaces/dcm/plugins/dcm-common/report.api.md +++ b/workspaces/dcm/plugins/dcm-common/report.api.md @@ -121,7 +121,6 @@ export interface CatalogItemInstance { display_name: string; // (undocumented) path?: string; - resource_id?: string; // (undocumented) spec: CatalogItemInstanceSpec; // (undocumented) @@ -142,6 +141,7 @@ export interface CatalogItemInstanceList { export interface CatalogItemInstanceSpec { // (undocumented) catalog_item_id: string; + resource_ids?: string[]; // (undocumented) user_values: UserValue[]; } @@ -156,10 +156,15 @@ export interface CatalogItemList { // @public export interface CatalogItemSpec { - // (undocumented) + resources?: CatalogResource[]; +} + +// @public +export interface CatalogResource { fields?: FieldConfiguration[]; - // (undocumented) - service_type?: string; + name: string; + requires_resources?: string[]; + service_type: string; } // @public @@ -529,6 +534,7 @@ export interface ServiceTypeList { export interface UserValue { // (undocumented) path: string; + resource: string; // (undocumented) value: unknown; } diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts index 50679fdc72b..6e7d2c41cf2 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts @@ -27,8 +27,8 @@ const MOCK_INSTANCE: CatalogItemInstance = { spec: { catalog_item_id: 'ci-1', user_values: [], + resource_ids: ['res-new'], }, - resource_id: 'res-new', }; function makeClient(fetchFn: jest.Mock) { diff --git a/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts b/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts index 3218a9a6d0d..b4316b3922f 100644 --- a/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts +++ b/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts @@ -50,11 +50,26 @@ export interface CatalogItem { /** Spec section of a {@link CatalogItem}. */ export interface CatalogItemSpec { - service_type?: string; + /** One or more named resources — each declares a service type and field configs. */ + resources?: CatalogResource[]; +} + +/** + * A named resource within a {@link CatalogItemSpec}. + * `name` and `service_type` are immutable after creation. + */ +export interface CatalogResource { + /** Unique identifier within the catalog item (e.g. "app", "ordersDb"). */ + name: string; + /** The service type for this resource (e.g. "vm", "three-tier-app-demo"). */ + service_type: string; + /** Names of other resources that must be ready before this one is provisioned. */ + requires_resources?: string[]; + /** Field configurations for this resource. */ fields?: FieldConfiguration[]; } -/** A single field within a {@link CatalogItemSpec}. */ +/** A single field within a {@link CatalogResource}. */ export interface FieldConfiguration { path: string; display_name?: string; @@ -77,8 +92,6 @@ export interface CatalogItemInstance { api_version: string; display_name: string; spec: CatalogItemInstanceSpec; - /** External resource identifier (readOnly). */ - resource_id?: string; path?: string; create_time?: string; update_time?: string; @@ -88,10 +101,14 @@ export interface CatalogItemInstance { export interface CatalogItemInstanceSpec { catalog_item_id: string; user_values: UserValue[]; + /** External resource identifiers assigned by the Placement Manager (readOnly). */ + resource_ids?: string[]; } /** A user-supplied value for a field in a {@link CatalogItemInstanceSpec}. */ export interface UserValue { + /** The resource name within the catalog item this value targets. */ + resource: string; path: string; value: unknown; } diff --git a/workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx b/workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx new file mode 100644 index 00000000000..c1374306a45 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx @@ -0,0 +1,279 @@ +/* + * 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, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import CodeIcon from '@material-ui/icons/Code'; +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; +import json from 'react-syntax-highlighter/dist/esm/languages/hljs/json'; +import docco from 'react-syntax-highlighter/dist/esm/styles/hljs/docco'; +import { useTranslation } from '../hooks/useTranslation'; +import { validateJsonObject } from '../utils/validateJsonObject'; + +SyntaxHighlighter.registerLanguage('json', json); + +const useStyles = makeStyles(theme => ({ + schemaLabel: { + marginBottom: theme.spacing(0.5), + }, + dialogContent: { + paddingTop: theme.spacing(1), + }, + editorWrapper: { + position: 'relative' as const, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + overflow: 'hidden', + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 1px ${theme.palette.primary.main}`, + }, + }, + editorWrapperError: { + borderColor: theme.palette.error.main, + '&:focus-within': { + borderColor: theme.palette.error.main, + boxShadow: `0 0 0 1px ${theme.palette.error.main}`, + }, + }, + editorTextarea: { + position: 'absolute' as const, + top: 0, + left: 0, + width: '100%', + height: '100%', + margin: 0, + padding: '12px', + border: 'none', + outline: 'none', + resize: 'none' as const, + background: 'transparent', + color: 'transparent', + caretColor: theme.palette.text.primary, + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontSize: 13, + lineHeight: '1.45', + whiteSpace: 'pre' as const, + overflowWrap: 'normal' as const, + overflow: 'auto', + zIndex: 1, + WebkitTextFillColor: 'transparent', + }, + editorHighlight: { + margin: 0, + padding: '12px !important', + minHeight: 280, + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace !important', + fontSize: '13px !important', + lineHeight: '1.45 !important', + whiteSpace: 'pre' as const, + overflowWrap: 'normal' as const, + overflow: 'auto', + background: `${theme.palette.background.paper} !important`, + }, + editorHelperText: { + marginTop: theme.spacing(0.5), + display: 'block', + }, +})); + +function validateSchemaJsonRaw(raw: string): 'object' | 'syntax' | '' { + const result = validateJsonObject(raw); + if (result === '' || typeof result === 'object') return ''; + return result; +} + +function prettyPrintIfValid(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +export type SchemaButtonProps = Readonly<{ + value: string; + onChange: (v: string) => void; + /** Error on the stored value (e.g. from import or duplicate detection). */ + fieldError?: string; +}>; + +/** + * Inline JSON schema editor — shows a "Add JSON" / "Edit JSON" button that + * opens a syntax-highlighted textarea dialog. + */ +export function SchemaButton({ + value, + onChange, + fieldError, +}: SchemaButtonProps) { + const classes = useStyles(); + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(''); + const textareaRef = useRef(null); + const highlightRef = useRef(null); + + const jsonErrorCode = useMemo(() => validateSchemaJsonRaw(draft), [draft]); + let jsonError = ''; + if (jsonErrorCode === 'object') { + jsonError = t('catalogItems.form.schemaMustBeObject'); + } else if (jsonErrorCode === 'syntax') { + jsonError = t('catalogItems.form.schemaInvalidJson'); + } + const applyDisabled = draft.trim() !== '' && Boolean(jsonErrorCode); + const hasError = Boolean(draft.trim() && jsonErrorCode); + + const handleOpen = () => { + setDraft(value ? prettyPrintIfValid(value) : ''); + setOpen(true); + }; + + const handleApply = useCallback(() => { + if (applyDisabled) return; + onChange(draft.trim()); + setOpen(false); + }, [applyDisabled, draft, onChange]); + + const handleClose = () => setOpen(false); + + const syncScroll = () => { + if (textareaRef.current && highlightRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop; + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') return; + const afterEnter = `${draft}\n`; + const formatted = prettyPrintIfValid(afterEnter); + if (formatted !== afterEnter) { + e.preventDefault(); + setDraft(formatted); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const pasted = e.clipboardData.getData('text'); + const { selectionStart: start, selectionEnd: end } = e.currentTarget; + const afterPaste = + draft.slice(0, start ?? 0) + pasted + draft.slice(end ?? 0); + const formatted = prettyPrintIfValid(afterPaste); + if (formatted !== afterPaste) { + e.preventDefault(); + setDraft(formatted); + } + }; + + return ( + <> + + + {t('catalogItems.form.schemaLabel')} + + + + + {fieldError && ( + + {fieldError} + + )} + + + + {t('catalogItems.form.schemaDialogTitle')} + + +
+ + {draft || ' '} + +
+